import os
import re
import json
import base64
import mimetypes
import zipfile
from pathlib import Path, PurePosixPath

import requests

from backend.config import BS4_SUPPORT, OUTPUT_DIR, TOOLS_DIR, WORKSPACE_ROOT, _load_settings
from backend.content_extraction import extract_source
from backend.models import call_vision_sync

try:
    from backend.config import BeautifulSoup
except ImportError:
    BeautifulSoup = None


_WEATHER_QUERY_RE = re.compile(
    r"\b(weather|weer|forecast|voorspelling|temperature|temperatuur|"
    r"regen|rain|wind|zon|sun|bewolkt|cloudy|hagel|hail|sneeuw|snow|"
    r"warm|koud|cold|hot|graden|degrees)\b",
    re.IGNORECASE,
)

_WEATHER_LOCATION_RE = re.compile(
    r"(?:(?:weather|weer|forecast|voorspelling|temperatuur|temperature|"
    r"warm|koud|cold|hot|regen|rain|wind|graden|degrees)"
    r"[^.?!]{0,30}?\b(?:in|voor|van|at|for)\s+)"
    r"([A-Z\u00C0-\u024F][a-z\u00C0-\u024F]+(?:[\s\-][A-Z\u00C0-\u024F]?[a-z\u00C0-\u024F]+)*)",
    re.IGNORECASE,
)

_WEATHER_STRIP_WORDS = {
    "morgen", "tomorrow", "vandaag", "today", "overmorgen", "deze", "this",
    "week", "komende", "next", "volgende", "aanstaande", "coming",
}


def _weather_lookup(query: str) -> str:
    """Lookup weather via wttr.in (free, no API key). Returns empty string on failure."""
    match = _WEATHER_LOCATION_RE.search(query)
    if match:
        raw_loc = match.group(1).strip()
        parts = raw_loc.split()
        while parts and parts[-1].lower() in _WEATHER_STRIP_WORDS:
            parts.pop()
        location = " ".join(parts)
    else:
        weather_words = {
            "weather", "weer", "forecast", "voorspelling", "temperatuur",
            "temperature", "morgen", "tomorrow", "vandaag", "today",
            "overmorgen", "regen", "rain", "wind", "zon", "sun",
        }
        words = re.findall(r"\b[A-Z\u00C0-\u024F][a-z\u00C0-\u024F]+(?:[\s\-][A-Za-z\u00C0-\u024F]+)*\b", query)
        candidates = [word for word in words if word.lower() not in weather_words]
        location = candidates[-1] if candidates else ""
    if not location:
        return ""
    try:
        response = requests.get(
            f"https://wttr.in/{location}",
            params={"format": "j1"},
            headers={"User-Agent": "AgentOrchestrator/1.0"},
            timeout=10,
        )
        if response.status_code != 200:
            return ""
        data = response.json()
        if "data" in data and isinstance(data["data"], dict):
            data = data["data"]
        current = data.get("current_condition", [{}])[0]
        forecasts = data.get("weather", [])
        lines = [f"Weather for {location} (via wttr.in):\n"]
        if current:
            lines.append(
                f"Now: {current.get('weatherDesc', [{}])[0].get('value', '?')}, "
                f"{current.get('temp_C', '?')}°C (feels like {current.get('FeelsLikeC', '?')}°C), "
                f"wind {current.get('windspeedKmph', '?')} km/h {current.get('winddir16Point', '')}, "
                f"humidity {current.get('humidity', '?')}%, "
                f"precipitation {current.get('precipMM', '0')} mm"
            )
        for day in forecasts[:3]:
            date = day.get("date", "?")
            max_c = day.get("maxtempC", "?")
            min_c = day.get("mintempC", "?")
            desc_parts = []
            for hour in day.get("hourly", []):
                desc = (hour.get("weatherDesc") or [{}])[0].get("value", "")
                if desc and desc not in desc_parts:
                    desc_parts.append(desc)
            sunrise = day.get("astronomy", [{}])[0].get("sunrise", "") if day.get("astronomy") else ""
            sunset = day.get("astronomy", [{}])[0].get("sunset", "") if day.get("astronomy") else ""
            rain = max(float(hour.get("precipMM", 0)) for hour in day.get("hourly", [{"precipMM": "0"}]))
            lines.append(
                f"{date}: {min_c}–{max_c}°C, {', '.join(desc_parts[:3]) or '?'}, "
                f"rain up to {rain:.1f} mm"
                + (f", sunrise {sunrise}, sunset {sunset}" if sunrise else "")
            )
        result = "\n".join(lines)
        print(f"[search] wttr.in returned weather for {location!r}")
        return result
    except Exception as exc:
        print(f"[search] wttr.in error: {exc}")
        return ""


def _ddg_search(query: str, max_results: int = 6) -> str:
    """Web search — Bing News RSS (primary) with Wikipedia API fallback."""
    import xml.etree.ElementTree as ET

    browser_ua = (
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
        "AppleWebKit/537.36 (KHTML, like Gecko) "
        "Chrome/120.0.0.0 Safari/537.36"
    )

    try:
        response = requests.get(
            "https://www.bing.com/news/search",
            params={"q": query, "format": "rss"},
            headers={"User-Agent": browser_ua},
            timeout=12,
        )
        if response.status_code == 200 and "xml" in response.headers.get("content-type", ""):
            root = ET.fromstring(response.text)
            channel = root.find("channel")
            rss_items = channel.findall("item") if channel is not None else []
            lines = []
            for item in rss_items[:max_results]:
                title = (item.findtext("title") or "").strip()
                link = (item.findtext("link") or "").strip()
                desc = re.sub(r"<[^>]+>", "", (item.findtext("description") or "").strip())
                if title and link:
                    lines.append(f"Title: {title}\nURL:   {link}\nSnippet: {desc}\n")
            if lines:
                print(f"[search] Bing RSS returned {len(lines)} results for: {query!r}")
                return "\n".join(lines)
    except Exception as exc:
        print(f"[search] Bing RSS error: {exc}")

    try:
        response = requests.get(
            "https://en.wikipedia.org/w/api.php",
            params={
                "action": "query",
                "list": "search",
                "srsearch": query,
                "srlimit": max_results,
                "format": "json",
            },
            headers={"User-Agent": "AgentBot/1.0 (research assistant)"},
            timeout=10,
        )
        if response.status_code == 200:
            hits = response.json().get("query", {}).get("search", [])
            lines = []
            for hit in hits:
                title = hit.get("title", "")
                snippet = re.sub(r"<[^>]+>", "", hit.get("snippet", ""))
                url = f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}"
                lines.append(f"Title: {title}\nURL:   {url}\nSnippet: {snippet}\n")
            if lines:
                print(f"[search] Wikipedia returned {len(lines)} results for: {query!r}")
                return "\n".join(lines)
    except Exception as exc:
        print(f"[search] Wikipedia error: {exc}")

    return "No search results found."


def _brave_search(query: str, api_key: str, max_results: int = 6) -> str:
    if not api_key:
        return "Brave Search not configured: missing API key."
    try:
        response = requests.get(
            "https://api.search.brave.com/res/v1/web/search",
            params={"q": query, "count": max_results},
            headers={
                "Accept": "application/json",
                "X-Subscription-Token": api_key,
            },
            timeout=12,
        )
        if response.status_code in {401, 403}:
            return "Brave Search auth error: invalid API key."
        response.raise_for_status()
        payload = response.json() if response.content else {}
        items = (payload.get("web") or {}).get("results") or []
        lines = []
        for item in items[:max_results]:
            title = str(item.get("title", "") or "").strip()
            url = str(item.get("url", "") or "").strip()
            snippet = str(item.get("description", "") or "").strip()
            if title and url:
                lines.append(f"Title: {title}\nURL:   {url}\nSnippet: {snippet}\n")
        if lines:
            print(f"[search] Brave returned {len(lines)} results for: {query!r}")
            return "\n".join(lines)
        return "No search results found."
    except Exception as exc:
        return f"Brave Search error: {exc}"


def _web_search(query: str, max_results: int = 6) -> str:
    if _WEATHER_QUERY_RE.search(query):
        weather = _weather_lookup(query)
        if weather:
            return weather
    settings = _load_settings()
    brave_key = (settings.get("brave_api_key", "") or "").strip()
    if brave_key:
        brave_result = _brave_search(query, brave_key, max_results=max_results)
        if not (
            brave_result.startswith("Brave Search error:")
            or brave_result.startswith("Brave Search auth error:")
            or brave_result.startswith("Brave Search not configured:")
        ):
            return brave_result
        print(f"[search] Brave failed, falling back: {brave_result}")
    return _ddg_search(query, max_results=max_results)


def _fetch_url(url: str, max_chars: int = 12000) -> str:
    try:
        response = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
        response.raise_for_status()
        if BS4_SUPPORT and BeautifulSoup:
            soup = BeautifulSoup(response.text, "html.parser")
            for tag in soup(["script", "style", "nav", "footer"]):
                tag.decompose()
            text = soup.get_text(separator="\n", strip=True)
        else:
            text = response.text
        return text[:max_chars]
    except Exception as exc:
        return f"Could not fetch {url}: {exc}"


def _extract_source(source: str, max_chars: int = 24000) -> str:
    return extract_source(source, max_chars=max_chars)


def _run_python(code: str, timeout: int = 20) -> str:
    """Execute Python code in a subprocess and return stdout / stderr."""
    import os as _os
    import subprocess
    import sys
    import tempfile

    OUTPUT_DIR.mkdir(exist_ok=True)
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as file_handle:
        file_handle.write(code)
        tmp_path = file_handle.name
    try:
        proc = subprocess.run(
            [sys.executable, tmp_path],
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=str(WORKSPACE_ROOT.resolve()),
            env={
                **os.environ,
                "PYTHONPATH": os.pathsep.join([
                    str(WORKSPACE_ROOT.resolve()),
                    str(TOOLS_DIR.resolve()),
                    os.environ.get("PYTHONPATH", ""),
                ]).rstrip(os.pathsep),
            },
        )
        stdout = (proc.stdout or "").strip()
        stderr = (proc.stderr or "").strip()
        if stderr:
            return f"STDOUT:\n{stdout}\nSTDERR:\n{stderr}" if stdout else f"STDERR:\n{stderr}"
        return stdout or "(no output)"
    except subprocess.TimeoutExpired:
        return f"Timed out after {timeout}s"
    except Exception as exc:
        return f"Execution error: {exc}"
    finally:
        try:
            _os.unlink(tmp_path)
        except Exception:
            pass


def _run_shell(command: str, timeout: int = 30) -> str:
    """Run a shell command and return combined stdout + stderr."""
    import subprocess

    blocked = ["rm -rf /", "format ", "del /f /s /q c:\\", ":(){ :|:& };:"]
    blocked_re = [
        r"\brm\s+.*-[^\s]*r[^\s]*f\b",
        r"\bformat\s+[a-zA-Z]:",
        r"\bdd\s+.*\bof=/dev/",
        r"\b(shutdown|reboot|halt)\b",
        r">\s*/dev/sd[a-z]",
        r"\breg\s+delete\b",
        r"\bnet\s+user\b.*\b/add\b",
        r"\bpowershell\b.*-[Ee]nc(odedCommand)?\b",
        r"\bcertutil\b.*-decode\b",
        r"\bcurl\b.*\|\s*(ba)?sh\b",
        r"\bwget\b.*\|\s*(ba)?sh\b",
        r"\b(Invoke-Expression|iex)\b",
        r"\bStart-Process\b.*-Verb\s+RunAs\b",
        r"\bschtasks\b.*/(create|run)\b",
        r"\bwmic\b.*process\b.*call\b.*create\b",
    ]
    cmd_lower = command.lower()
    for item in blocked:
        if item.lower() in cmd_lower:
            return f"Blocked: command matches safety filter ({item!r})"
    for pattern in blocked_re:
        if re.search(pattern, command, re.IGNORECASE):
            return "Blocked: command matches safety pattern"

    from backend.memory import _INSTALLED_PACKAGES

    pip_match = re.match(r"pip\s+install\s+(.+)", command.strip(), re.IGNORECASE)
    if pip_match:
        requested = [
            pkg.strip().lower().split("==")[0].split(">=")[0].split("<=")[0]
            for pkg in pip_match.group(1).split()
            if not pkg.startswith("-")
        ]
        already = [pkg for pkg in requested if pkg in _INSTALLED_PACKAGES.lower().replace("-", "_").replace(" ", "_")]
        if already and all(pkg in _INSTALLED_PACKAGES.lower() for pkg in already):
            return f"Skipped: {', '.join(already)} already installed."
    try:
        proc = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=timeout,
            cwd=str(WORKSPACE_ROOT.resolve()),
            env={
                **os.environ,
                "PYTHONPATH": os.pathsep.join([
                    str(WORKSPACE_ROOT.resolve()),
                    str(TOOLS_DIR.resolve()),
                    os.environ.get("PYTHONPATH", ""),
                ]).rstrip(os.pathsep),
            },
        )
        stdout = (proc.stdout or "").strip()
        stderr = (proc.stderr or "").strip()
        parts = []
        if stdout:
            parts.append(f"STDOUT:\n{stdout}")
        if stderr:
            parts.append(f"STDERR:\n{stderr}")
        parts.append(f"Exit code: {proc.returncode}")
        return "\n".join(parts) or "(no output)"
    except subprocess.TimeoutExpired:
        return f"Timed out after {timeout}s"
    except Exception as exc:
        return f"Shell error: {exc}"


def _fetch_webhook(webhook_name: str, params: dict | None = None) -> str:
    settings = _load_settings()
    webhooks = settings.get("webhooks") or []
    webhook = None
    for item in webhooks:
        if item.get("name", "").lower() == webhook_name.lower():
            webhook = item
            break
    if not webhook:
        available = ", ".join(item.get("name", "?") for item in webhooks) or "(none configured)"
        return f"Webhook '{webhook_name}' not found. Available webhooks: {available}"
    url = webhook.get("url", "")
    if not url:
        return f"Webhook '{webhook_name}' has no URL configured."
    method = (webhook.get("method") or "GET").upper()
    headers: dict[str, str] = {}
    auth_type = webhook.get("auth_type", "none")
    auth_value = webhook.get("auth_value", "")
    if auth_type == "bearer" and auth_value:
        headers["Authorization"] = f"Bearer {auth_value}"
    elif auth_type == "api_key" and auth_value:
        headers["X-API-Key"] = auth_value
    try:
        if method == "GET":
            response = requests.get(url, headers=headers, params=params, timeout=20)
        else:
            headers.setdefault("Content-Type", "application/json")
            response = requests.post(url, headers=headers, json=params, timeout=20)
        ct = response.headers.get("content-type", "")
        text = response.text[:8000]
        return f"Status: {response.status_code}\nContent-Type: {ct}\n\n{text}"
    except Exception as exc:
        return f"Webhook error: {exc}"


def _http_request(
    method: str,
    url: str,
    headers: dict | None = None,
    body: str | None = None,
    timeout: int = 20,
) -> str:
    try:
        response = requests.request(
            method.upper(),
            url,
            headers=headers or {},
            data=body.encode() if isinstance(body, str) else body,
            timeout=timeout,
        )
        ct = response.headers.get("content-type", "")
        text = response.text[:8000]
        return f"Status: {response.status_code}\nContent-Type: {ct}\n\n{text}"
    except Exception as exc:
        return f"HTTP error: {exc}"


_WORKSPACE_SKIP_DIRS = {
    ".git", ".venv", "venv", "node_modules", "__pycache__", ".mypy_cache",
    ".pytest_cache", ".ruff_cache", "dist", "build", ".idea", ".vscode",
    "release",
}
_WORKSPACE_SKIP_SUFFIXES = {
    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".exe", ".dll",
    ".zip", ".bin", ".pyc", ".so", ".dylib", ".ico", ".woff", ".woff2",
    ".ttf", ".otf", ".mp3", ".mp4", ".wav", ".mov", ".mkv", ".webm",
}

_WEB_APP_ROOT = WORKSPACE_ROOT / "generated_apps"
_WEB_APP_PACKAGE_DIR = OUTPUT_DIR / "web_apps"
_WEB_APP_ALLOWED_TEXT_EXTENSIONS = {
    ".html", ".css", ".js", ".json", ".txt", ".md", ".svg", ".webmanifest",
}
_WEB_APP_SKIP_PARTS = {".git", ".venv", "__pycache__"}
_WEB_APP_HTML_EXTENSIONS = {".html", ".htm"}


def _is_skipped_path(path) -> bool:
    if path.suffix.lower() in _WORKSPACE_SKIP_SUFFIXES:
        return True
    parts = set(path.parts)
    return bool(parts & _WORKSPACE_SKIP_DIRS)


def _normalize_rel_path(rel_path: str) -> str:
    return str(rel_path or "").replace("\\", "/").strip().lstrip("./").lstrip("/")


def _normalize_web_app_project(project: str) -> str:
    norm = _normalize_rel_path(project)
    if norm.lower().startswith("generated_apps/"):
        norm = norm.split("/", 1)[1] if "/" in norm else ""
    return norm


def _resolve_web_app_dir(project: str) -> tuple[Path | None, str | None]:
    norm = _normalize_web_app_project(project)
    if not norm:
        return None, "Write error: 'project' must be a non-empty app name or path under generated_apps/."
    parts = [part for part in PurePosixPath(norm).parts if part not in ("", ".")]
    if not parts or any(part == ".." for part in parts):
        return None, "Write error: project path must stay inside generated_apps/."
    target = (_WEB_APP_ROOT.joinpath(*parts)).resolve()
    try:
        if not target.is_relative_to(_WEB_APP_ROOT.resolve()):
            return None, "Write error: project path resolved outside generated_apps/."
    except Exception:
        return None, "Write error: invalid project path."
    return target, None


def _resolve_web_app_file_path(project_dir: Path, rel_path: str) -> tuple[Path | None, str | None]:
    norm = _normalize_rel_path(rel_path)
    if not norm:
        return None, "Write error: each file must include a relative path."
    parts = [part for part in PurePosixPath(norm).parts if part not in ("", ".")]
    if not parts or any(part == ".." for part in parts):
        return None, f"Write error: invalid file path '{rel_path}'."
    target = project_dir.joinpath(*parts).resolve()
    try:
        if not target.is_relative_to(project_dir.resolve()):
            return None, f"Write error: file path '{rel_path}' resolved outside the project folder."
    except Exception:
        return None, f"Write error: invalid file path '{rel_path}'."
    suffix = target.suffix.lower()
    if suffix not in _WEB_APP_ALLOWED_TEXT_EXTENSIONS:
        return None, (
            f"Write error: unsupported file type '{suffix or '[none]'}' for '{rel_path}'. "
            "Allowed types: .html, .css, .js, .json, .txt, .md, .svg, .webmanifest"
        )
    return target, None


def _coerce_web_file_entries(files) -> tuple[list[tuple[str, str]] | None, str | None]:
    payload = files
    if isinstance(files, str):
        raw = files.strip()
        if not raw:
            return None, "Write error: 'files' must not be empty."
        try:
            payload = json.loads(raw)
        except json.JSONDecodeError as exc:
            return None, f"Write error: 'files' JSON could not be parsed ({exc})."
    if isinstance(payload, dict):
        payload = payload.get("files") if isinstance(payload.get("files"), list) else [payload]
    if not isinstance(payload, list) or not payload:
        return None, "Write error: 'files' must be a non-empty list of objects with path and content."

    entries: list[tuple[str, str]] = []
    for item in payload:
        if not isinstance(item, dict):
            return None, "Write error: every file entry must be an object with 'path' and 'content'."
        rel_path = str(item.get("path") or item.get("filename") or "").strip()
        if not rel_path:
            return None, "Write error: every file entry must include 'path'."
        content = item.get("content", "")
        if content is None:
            content = ""
        if not isinstance(content, str):
            content = str(content)
        entries.append((rel_path, content))
    return entries, None


def _relative_workspace_path(path: Path) -> str:
    try:
        return str(path.resolve().relative_to(WORKSPACE_ROOT.resolve())).replace("\\", "/")
    except Exception:
        return str(path).replace("\\", "/")


def _write_web_files(project: str, files, overwrite: bool = True) -> str:
    project_dir, error = _resolve_web_app_dir(project)
    if error:
        return error
    entries, error = _coerce_web_file_entries(files)
    if error:
        return error

    project_dir.mkdir(parents=True, exist_ok=True)
    written: list[str] = []
    skipped: list[str] = []
    for rel_path, content in entries or []:
        target, file_error = _resolve_web_app_file_path(project_dir, rel_path)
        if file_error:
            return file_error
        if target.exists() and not overwrite:
            skipped.append(_relative_workspace_path(target))
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(content, encoding="utf-8")
        written.append(_relative_workspace_path(target))

    if not written and skipped:
        return "Web app files skipped because overwrite=false:\n" + "\n".join(skipped)

    lines = [f"Web app files saved in {_relative_workspace_path(project_dir)}:"]
    lines.extend(written)
    if skipped:
        lines.append("Skipped existing files:")
        lines.extend(skipped)
    if not (project_dir / "index.html").exists():
        lines.append("Warning: index.html is still missing from the project root.")
    return "\n".join(lines)


def _package_web_app(project: str, zip_name: str = "") -> str:
    project_dir, error = _resolve_web_app_dir(project)
    if error:
        return error.replace("Write error", "Package error")
    if not project_dir.exists() or not project_dir.is_dir():
        return f"Package error: project folder not found: {_relative_workspace_path(project_dir)}"
    index_file = project_dir / "index.html"
    if not index_file.exists():
        return f"Package error: missing {_relative_workspace_path(index_file)}"

    _WEB_APP_PACKAGE_DIR.mkdir(parents=True, exist_ok=True)
    archive_name = Path(zip_name or f"{project_dir.name}.zip").name
    if not archive_name.lower().endswith(".zip"):
        archive_name += ".zip"
    zip_path = _WEB_APP_PACKAGE_DIR / archive_name

    file_count = 0
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
        for path in sorted(project_dir.rglob("*")):
            if not path.is_file():
                continue
            rel = path.relative_to(project_dir)
            if any(part in _WEB_APP_SKIP_PARTS for part in rel.parts):
                continue
            archive.write(path, arcname=str(rel).replace("\\", "/"))
            file_count += 1

    if file_count < 1:
        zip_path.unlink(missing_ok=True)
        return "Package error: project folder contains no packageable files."

    return (
        f"Web app packaged: {_relative_workspace_path(zip_path)} "
        f"({file_count} file(s)) from {_relative_workspace_path(project_dir)}"
    )


def _preview_web_app(
    project: str,
    html_path: str = "index.html",
    selector: str = "body",
    max_chars: int = 2000,
) -> str:
    project_dir, error = _resolve_web_app_dir(project)
    if error:
        return error.replace("Write error", "Preview error")
    target, file_error = _resolve_web_app_file_path(project_dir, html_path)
    if file_error:
        return file_error.replace("Write error", "Preview error")
    if target.suffix.lower() not in _WEB_APP_HTML_EXTENSIONS:
        return f"Preview error: only HTML files can be previewed, got '{target.suffix or '[none]'}'."
    if not target.exists():
        return f"Preview error: file not found: {_relative_workspace_path(target)}"

    from backend.browser_runtime import _browser_get_text, _browser_navigate

    nav_result = _browser_navigate(target.resolve().as_uri())
    if nav_result.lower().startswith("browser ") or nav_result.lower().startswith("chromium "):
        return nav_result
    text_result = _browser_get_text(selector=selector or "body", max_chars=max_chars)
    preview_path = OUTPUT_DIR / "browser_preview_live.png"
    lines = [
        f"Web app previewed: {_relative_workspace_path(target)}",
        nav_result,
        text_result,
    ]
    if preview_path.exists():
        lines.append(f"Preview image: {_relative_workspace_path(preview_path)}")
    return "\n".join(lines)


def _find_path_case_insensitive(rel_path: str):
    """Resolve a relative path tolerating case differences and missing prefixes."""
    norm = _normalize_rel_path(rel_path)
    if not norm:
        return None
    direct = (WORKSPACE_ROOT / norm)
    if direct.exists():
        return direct.resolve()
    target_lower = norm.lower()
    target_basename = target_lower.rsplit("/", 1)[-1]
    candidates = []
    for path in WORKSPACE_ROOT.rglob(target_basename):
        if not path.is_file():
            continue
        if _is_skipped_path(path.relative_to(WORKSPACE_ROOT)):
            continue
        rel = str(path.relative_to(WORKSPACE_ROOT)).replace("\\", "/").lower()
        if rel == target_lower or rel.endswith("/" + target_lower) or rel.endswith(target_lower):
            return path.resolve()
        candidates.append(path.resolve())
        if len(candidates) >= 5:
            break
    return candidates[0] if len(candidates) == 1 else None


def _read_workspace_file(rel_path: str, max_chars: int = 16000) -> str:
    try:
        norm = _normalize_rel_path(rel_path)
        if not norm:
            return "Read error: empty path."
        target = (WORKSPACE_ROOT / norm).resolve()
        if not target.is_relative_to(WORKSPACE_ROOT):
            return "Access denied: path is outside the workspace root."
        if not target.exists():
            # Tolerate case differences and missing parent prefixes (e.g. "mcp_runtime.py").
            alt = _find_path_case_insensitive(norm)
            if alt is None:
                # Suggest near-matches by basename to help the planner self-correct.
                basename = norm.rsplit("/", 1)[-1].lower()
                hints: list[str] = []
                if basename:
                    for path in WORKSPACE_ROOT.rglob("*"):
                        if not path.is_file():
                            continue
                        rel = path.relative_to(WORKSPACE_ROOT)
                        if _is_skipped_path(rel):
                            continue
                        if path.name.lower() == basename:
                            hints.append(str(rel).replace("\\", "/"))
                            if len(hints) >= 5:
                                break
                if hints:
                    return f"File not found: {rel_path}. Did you mean: {', '.join(hints)}"
                return f"File not found: {rel_path}"
            target = alt
        return target.read_text(encoding="utf-8", errors="replace")[:max_chars]
    except Exception as exc:
        return f"Read error: {exc}"


def _looks_like_path_pattern(pattern: str) -> bool:
    p = (pattern or "").strip()
    if not p:
        return False
    if any(ch in p for ch in ("/", "\\")):
        return True
    # Single token with a known file extension → treat as filename
    if " " not in p and "." in p:
        ext = p.rsplit(".", 1)[-1].lower()
        if 1 <= len(ext) <= 6 and ext.isalnum():
            return True
    return False


def _search_workspace(
    pattern: str,
    glob: str = "**/*",
    max_results: int = 250,
    *,
    regex: bool = False,
    mode: str = "auto",
) -> str:
    """Substring-by-default workspace search.

    mode:
      - "auto" (default): if pattern looks like a path/filename, search by
        filename; otherwise do a case-insensitive substring scan of file
        contents.
      - "filename": only search filenames.
      - "content": only search file contents.
    Set regex=True to opt into regex matching for content searches.
    """
    pattern = str(pattern or "").strip()
    if not pattern:
        return "Search error: empty pattern."

    chosen_mode = mode if mode in {"filename", "content", "auto"} else "auto"
    if chosen_mode == "auto":
        chosen_mode = "filename" if _looks_like_path_pattern(pattern) else "content"

    try:
        matches: list[str] = []
        if chosen_mode == "filename":
            needle = _normalize_rel_path(pattern).lower()
            basename = needle.rsplit("/", 1)[-1]
            for path in WORKSPACE_ROOT.rglob(basename or "*"):
                if not path.is_file():
                    continue
                rel = path.relative_to(WORKSPACE_ROOT)
                if _is_skipped_path(rel):
                    continue
                rel_str = str(rel).replace("\\", "/")
                if "/" in needle and not rel_str.lower().endswith(needle):
                    continue
                try:
                    size = path.stat().st_size
                except OSError:
                    size = 0
                matches.append(f"{rel_str}  ({size:,} bytes)")
                if len(matches) >= max_results:
                    break
            if not matches:
                return f"No files match: {pattern}"
            header = f"Found {len(matches)} file(s) matching {pattern!r}:"
            return header + "\n" + "\n".join(matches)

        # Content search (substring by default, regex on opt-in)
        compiled = None
        if regex:
            try:
                compiled = re.compile(pattern, re.IGNORECASE)
            except re.error as exc:
                return f"Search error: invalid regex: {exc}"
        needle_lower = pattern.lower()

        for path in WORKSPACE_ROOT.glob(glob):
            if not path.is_file():
                continue
            rel = path.relative_to(WORKSPACE_ROOT)
            if _is_skipped_path(rel):
                continue
            try:
                with open(path, "r", encoding="utf-8", errors="replace") as handle:
                    rel_str = str(rel).replace("\\", "/")
                    for lineno, line in enumerate(handle, 1):
                        if compiled is not None:
                            hit = compiled.search(line) is not None
                        else:
                            hit = needle_lower in line.lower()
                        if hit:
                            matches.append(f"{rel_str}:{lineno}: {line.rstrip()}")
                            if len(matches) >= max_results:
                                return "\n".join(matches)
            except Exception:
                continue
        return "\n".join(matches) if matches else "No matches found."
    except Exception as exc:
        return f"Search error: {exc}"


# ── Image description (vision) ───────────────────────────────────────────────
_IMAGE_EXTENSIONS = {
    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".tif", ".heic",
}

_IMAGE_LOOKUP_DIRS_REL = ("output", "attachments", "downloads")

_DESCRIBE_IMAGE_DEFAULT_PROMPT = (
    "Describe only what is directly visible in this image. "
    "List the main subjects, visible objects, readable text, clothing, colours, and the setting. "
    "If a detail is small, blurry, blocked, or ambiguous, explicitly say that it is uncertain. "
    "Use neutral nouns like person or people unless gender or age is directly evident from the image or visible text. "
    "Do not guess identity, relationship, age, profession, location, brand, exact time of day, or intent unless it is directly visible. "
    "Do not invent text that cannot be read clearly."
)

_DESCRIBE_IMAGE_SYSTEM_PROMPT = (
    "You are a strict visual description system. Base every statement only on directly visible evidence in the image. "
    "Prefer neutral wording for uncertain attributes. "
    "If something is ambiguous, occluded, cropped, blurry, or too small to verify, state that explicitly instead of guessing. "
    "Use generic labels like person or people instead of inferred age or gender when that is not certain. "
    "Do not infer identity, relationship, age, profession, ethnicity, location, brand, or intent unless it is directly visible or written in the image. "
    "Only report readable text when it is clearly legible. Reply in the same language as the user's prompt."
)

_DESCRIBE_IMAGE_GUARDRAILS = (
    "Additional constraints: describe only visible evidence; mark uncertain details as uncertain; "
    "use neutral labels like person/people when age or gender is not certain; "
    "do not guess identity, relationship, profession, location, brand, time of day, or intent; "
    "do not invent unreadable text."
)


def _is_image_filename(name: str) -> bool:
    return Path(name).suffix.lower() in _IMAGE_EXTENSIONS


def _resolve_image_source(source: str) -> tuple[bytes, str, str] | str:
    """Resolve ``source`` to (raw_bytes, mime_type, display_name).

    ``source`` may be a URL, an absolute path, a workspace-relative path, or a
    plain filename inside output/, attachments/ or downloads/. Returns an error
    message string on failure.
    """
    src = (source or "").strip()
    if not src:
        return "Error: empty image source."

    # 1. data: URI
    if src.lower().startswith("data:"):
        try:
            header, _, data = src.partition(",")
            mime = header.split(";", 1)[0][5:] or "image/png"
            raw = base64.b64decode(data)
            return raw, mime, "inline"
        except Exception as exc:
            return f"Error decoding data URI: {exc}"

    # 2. http(s) URL
    if src.lower().startswith(("http://", "https://")):
        try:
            r = requests.get(src, timeout=30, headers={"User-Agent": "AgentOrchestrator/1.0"})
            r.raise_for_status()
            mime = (r.headers.get("content-type") or "").split(";", 1)[0].strip() or "image/png"
            if not mime.startswith("image/"):
                return f"Error: URL did not return an image (Content-Type: {mime})."
            name = Path(src.split("?", 1)[0]).name or "image"
            return r.content, mime, name
        except Exception as exc:
            return f"Error downloading image: {exc}"

    # 3. Local path — try as-is, then under common output/attachment dirs.
    candidate_paths: list[Path] = []
    raw_path = Path(src)
    if raw_path.is_absolute():
        candidate_paths.append(raw_path)
    else:
        candidate_paths.append(WORKSPACE_ROOT / raw_path)
        for rel in _IMAGE_LOOKUP_DIRS_REL:
            candidate_paths.append(WORKSPACE_ROOT / rel / raw_path.name)

    for path in candidate_paths:
        try:
            resolved = path.resolve()
        except OSError:
            continue
        if not resolved.exists() or not resolved.is_file():
            continue
        try:
            data = resolved.read_bytes()
        except OSError as exc:
            return f"Error reading image: {exc}"
        mime, _ = mimetypes.guess_type(str(resolved))
        if not mime:
            mime = "image/png"
        return data, mime, resolved.name

    return f"Error: image not found at {src!r}. Tried absolute path and {', '.join(_IMAGE_LOOKUP_DIRS_REL)}/."


def _describe_image(source: str, prompt: str = "", model: str = "") -> str:
    """Return a natural-language description of the image at ``source``."""
    resolved = _resolve_image_source(source)
    if isinstance(resolved, str):
        return resolved
    raw_bytes, mime, name = resolved
    if not raw_bytes:
        return "Error: image is empty."
    b64 = base64.b64encode(raw_bytes).decode("ascii")

    requested_prompt = (prompt or "").strip()
    user_prompt = requested_prompt or _DESCRIBE_IMAGE_DEFAULT_PROMPT
    if requested_prompt:
        user_prompt = f"{requested_prompt}\n\n{_DESCRIBE_IMAGE_GUARDRAILS}"
    system = _DESCRIBE_IMAGE_SYSTEM_PROMPT
    result = call_vision_sync(user_prompt, [(mime, b64)], system=system, model=model)
    if isinstance(result, str) and result.startswith("Error calling "):
        return result
    return f"[Image: {name} ({mime}, {len(raw_bytes):,} bytes)]\n{result}"