# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Nextcloud Skill

CalDAV calendar, CardDAV contacts, and WebDAV files.
Works with any standards-compliant server (Nextcloud, Radicale, ownCloud, Synology).

Setup:
    NEXTCLOUD_URL=https://cloud.example.org
    NEXTCLOUD_USER=alice
    NEXTCLOUD_TOKEN=xxxx-xxxx-xxxx-xxxx
"""

import logging
import os
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta

import httpx

from familiar.core.utils import format_http_error

logger = logging.getLogger(__name__)

REQUEST_TIMEOUT = 30


# ---------------------------------------------------------------------------
# Configuration helpers
# ---------------------------------------------------------------------------


def _get_nextcloud_config() -> dict:
    return {
        "url": os.environ.get("NEXTCLOUD_URL", "").rstrip("/"),
        "user": os.environ.get("NEXTCLOUD_USER", ""),
        "token": os.environ.get("NEXTCLOUD_TOKEN", ""),
    }


def _nc_configured() -> bool:
    cfg = _get_nextcloud_config()
    return bool(cfg["url"] and cfg["user"] and cfg["token"])


def _nc_auth() -> tuple:
    cfg = _get_nextcloud_config()
    return (cfg["user"], cfg["token"])


def _get_caldav_url() -> str:
    cfg = _get_nextcloud_config()
    return f"{cfg['url']}/remote.php/dav/calendars/{cfg['user']}/"


def _get_carddav_url() -> str:
    cfg = _get_nextcloud_config()
    return f"{cfg['url']}/remote.php/dav/addressbooks/users/{cfg['user']}/contacts/"


def _get_webdav_url(path: str = "") -> str:
    cfg = _get_nextcloud_config()
    path = path.lstrip("/")
    return f"{cfg['url']}/remote.php/dav/files/{cfg['user']}/{path}"


def _get_timezone() -> str:
    tz = os.environ.get("CALENDAR_TIMEZONE", "")
    if not tz:
        try:
            from familiar.core.config import load_config

            _cfg = load_config(validate=False)
            tz = (
                getattr(_cfg, "timezone", "")
                or getattr(getattr(_cfg, "agent", None), "timezone", "")
                or ""
            )
        except Exception:
            pass
    return tz or "UTC"


# ---------------------------------------------------------------------------
# CalDAV Calendar Tools
# ---------------------------------------------------------------------------


def _get_caldav_client():
    """Return a caldav.DAVClient connected to the Nextcloud CalDAV endpoint."""
    try:
        import caldav
    except ImportError:
        from familiar.core.deps import CALDAV_PACKAGES, ensure_packages

        ok, _ = ensure_packages(CALDAV_PACKAGES)
        if ok:
            import caldav
        else:
            raise RuntimeError("CalDAV setup failed. Check server logs.")
    if not _nc_configured():
        raise RuntimeError(
            "Nextcloud not configured. Run /connect nextcloud to set up, or set NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_TOKEN."
        )
    cfg = _get_nextcloud_config()
    return caldav.DAVClient(
        url=_get_caldav_url(),
        username=cfg["user"],
        password=cfg["token"],
    )


def _parse_vevent(cal_data: str) -> list:
    """Parse VCALENDAR data and return list of event dicts."""
    try:
        import vobject
    except ImportError:
        return []
    events = []
    try:
        for cal in vobject.readComponents(cal_data):
            for vevent in cal.contents.get("vevent", []):
                ev = {}
                if hasattr(vevent, "summary"):
                    ev["title"] = str(vevent.summary.value)
                if hasattr(vevent, "dtstart"):
                    ev["start"] = vevent.dtstart.value
                if hasattr(vevent, "dtend"):
                    ev["end"] = vevent.dtend.value
                if hasattr(vevent, "location"):
                    ev["location"] = str(vevent.location.value)
                if hasattr(vevent, "description"):
                    ev["description"] = str(vevent.description.value)
                events.append(ev)
    except Exception as e:
        logger.warning(f"vobject parse error: {e}")
    return events


def _format_nc_event(ev: dict) -> str:
    start = ev.get("start")
    title = ev.get("title", "(No title)")
    if hasattr(start, "strftime"):
        time_str = start.strftime("%I:%M %p")
    else:
        time_str = str(start)
    line = f"  {time_str}: {title}"
    loc = ev.get("location")
    if loc:
        line += f"\n    Location: {loc}"
    return line


def nc_get_events(data: dict) -> str:
    """Get calendar events for a date range."""
    try:
        client = _get_caldav_client()
    except RuntimeError as e:
        return f"Error: {e}. Verify NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN are set correctly."

    date_str = data.get("date", "today")
    days = data.get("days", 1)

    now = datetime.now()
    if date_str == "today":
        start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
    elif date_str == "tomorrow":
        start_date = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
    elif date_str == "this week":
        start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
        days = 7
    else:
        try:
            start_date = datetime.strptime(date_str, "%Y-%m-%d")
        except ValueError:
            start_date = now.replace(hour=0, minute=0, second=0, microsecond=0)

    end_date = start_date + timedelta(days=days)

    try:
        principal = client.principal()
        calendars = principal.calendars()
        if not calendars:
            return "No calendars found on this account."

        all_events = []
        for cal in calendars:
            try:
                results = cal.date_search(start=start_date, end=end_date, expand=True)
                for event in results:
                    parsed = _parse_vevent(event.data)
                    all_events.extend(parsed)
            except Exception as e:
                logger.warning(f"Error searching calendar {cal}: {e}")

        if not all_events:
            return f"No events for {date_str}."

        all_events.sort(key=lambda e: str(e.get("start", "")))
        lines = [f"Calendar ({date_str}):"]
        for ev in all_events:
            lines.append(_format_nc_event(ev))
        return "\n".join(lines)

    except Exception as e:
        logger.error(format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud"))
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_create_event(data: dict) -> str:
    """Create a calendar event via CalDAV."""
    title = data.get("title", "").strip()
    if not title:
        return "Please provide an event title."

    start_str = data.get("start", "")
    duration_minutes = data.get("duration", 60)

    if not start_str:
        return "Please provide a start date/time."

    if not data.get("_confirmed"):
        from familiar.core.confirmations import caldav_create_preview, needs_confirmation

        cfg = _get_nextcloud_config()
        return needs_confirmation(
            "nextcloud_calendar_create",
            data,
            caldav_create_preview(title, start_str, duration_minutes, cfg["url"]),
            risk="medium",
        )

    try:
        import vobject

        client = _get_caldav_client()
    except (ImportError, RuntimeError) as e:
        return f"Error: {e}. Verify NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN are set correctly."

    try:
        start_dt = datetime.strptime(start_str, "%Y-%m-%d %H:%M")
    except ValueError:
        try:
            start_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M")
        except ValueError:
            return f"Could not parse start time: {start_str}. Use YYYY-MM-DD HH:MM format."

    end_dt = start_dt + timedelta(minutes=duration_minutes)

    cal = vobject.iCalendar()
    vevent = cal.add("vevent")
    vevent.add("summary").value = title
    vevent.add("dtstart").value = start_dt
    vevent.add("dtend").value = end_dt
    location = data.get("location", "")
    if location:
        vevent.add("location").value = location
    description = data.get("description", "")
    if description:
        vevent.add("description").value = description

    try:
        principal = client.principal()
        calendars = principal.calendars()
        if not calendars:
            return "No calendars found."
        calendars[0].save_event(cal.serialize())
        return f"Created: {title}\n  {start_dt.strftime('%A, %B %d at %I:%M %p')}\n  {duration_minutes} minutes"
    except Exception as e:
        logger.error(f"CalDAV create error: {e}")
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_check_availability(data: dict) -> str:
    """Check if a time slot is available."""
    try:
        client = _get_caldav_client()
    except RuntimeError as e:
        return f"Error: {e}. Verify NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN are set correctly."

    date_str = data.get("date", "today")
    time_str = data.get("time", "09:00")
    duration = data.get("duration", 60)

    now = datetime.now()
    if date_str == "today":
        day = now.date()
    elif date_str == "tomorrow":
        day = (now + timedelta(days=1)).date()
    else:
        try:
            day = datetime.strptime(date_str, "%Y-%m-%d").date()
        except ValueError:
            day = now.date()

    try:
        check_time = datetime.strptime(time_str, "%H:%M").time()
    except ValueError:
        check_time = datetime.strptime("09:00", "%H:%M").time()

    check_dt = datetime.combine(day, check_time)
    check_end = check_dt + timedelta(minutes=duration)

    try:
        principal = client.principal()
        calendars = principal.calendars()
        conflicts = []
        for cal in calendars:
            try:
                results = cal.date_search(start=check_dt, end=check_end, expand=True)
                for event in results:
                    parsed = _parse_vevent(event.data)
                    for ev in parsed:
                        conflicts.append(ev.get("title", "Busy"))
            except Exception:
                pass

        if not conflicts:
            return f"{check_dt.strftime('%A %I:%M %p')} is available ({duration} min slot)"
        return f"{check_dt.strftime('%A %I:%M %p')} has conflicts:\n" + "\n".join(
            f"  {c}" for c in conflicts
        )
    except Exception as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_find_free_time(data: dict) -> str:
    """Find available time slots on a given day."""
    try:
        client = _get_caldav_client()
    except RuntimeError as e:
        return f"Error: {e}. Verify NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN are set correctly."

    date_str = data.get("date", "today")
    duration = data.get("duration", 60)
    start_hour = data.get("start_hour", 9)
    end_hour = data.get("end_hour", 17)

    now = datetime.now()
    if date_str == "today":
        day = now.date()
    elif date_str == "tomorrow":
        day = (now + timedelta(days=1)).date()
    else:
        try:
            day = datetime.strptime(date_str, "%Y-%m-%d").date()
        except ValueError:
            day = now.date()

    day_start = datetime.combine(day, datetime.min.time().replace(hour=start_hour))
    day_end = datetime.combine(day, datetime.min.time().replace(hour=end_hour))

    try:
        principal = client.principal()
        calendars = principal.calendars()
        busy = []
        for cal in calendars:
            try:
                results = cal.date_search(start=day_start, end=day_end, expand=True)
                for event in results:
                    parsed = _parse_vevent(event.data)
                    for ev in parsed:
                        s = ev.get("start")
                        e = ev.get("end")
                        if hasattr(s, "hour") and hasattr(e, "hour"):
                            if not hasattr(s, "date"):
                                s = datetime.combine(day, s)
                            if not hasattr(e, "date"):
                                e = datetime.combine(day, e)
                            busy.append((s, e))
            except Exception:
                pass

        busy.sort()
        free_slots = []
        current = day_start
        for busy_start, busy_end in busy:
            if current + timedelta(minutes=duration) <= busy_start:
                free_slots.append((current, busy_start))
            current = max(current, busy_end)
        if current + timedelta(minutes=duration) <= day_end:
            free_slots.append((current, day_end))

        if not free_slots:
            return f"No {duration}-minute slots available on {day.strftime('%A, %B %d')}"

        lines = [f"Available times on {day.strftime('%A, %B %d')}:"]
        for slot_start, slot_end in free_slots[:5]:
            lines.append(f"  {slot_start.strftime('%I:%M %p')} - {slot_end.strftime('%I:%M %p')}")
        return "\n".join(lines)

    except Exception as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_delete_event(data: dict) -> str:
    """Delete a calendar event by title/date."""
    search = data.get("search", "").strip()
    if not search:
        return "Please provide an event title to search for."

    date_str = data.get("date", "today")
    now = datetime.now()
    if date_str == "today":
        day = now.date()
    elif date_str == "tomorrow":
        day = (now + timedelta(days=1)).date()
    else:
        try:
            day = datetime.strptime(date_str, "%Y-%m-%d").date()
        except ValueError:
            day = now.date()

    day_start = datetime.combine(day, datetime.min.time())
    day_end = day_start + timedelta(days=1)

    try:
        client = _get_caldav_client()
    except RuntimeError as e:
        return f"Error: {e}. Verify NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN are set correctly."

    try:
        principal = client.principal()
        calendars = principal.calendars()
        matches = []
        for cal in calendars:
            try:
                results = cal.date_search(start=day_start, end=day_end, expand=True)
                for event in results:
                    parsed = _parse_vevent(event.data)
                    for ev in parsed:
                        if search.lower() in ev.get("title", "").lower():
                            matches.append((event, ev))
            except Exception:
                pass

        if not matches:
            return f"No events matching '{search}' on {day.strftime('%B %d')}."

        event_obj, ev_data = matches[0]
        event_title = ev_data.get("title", search)
        event_start = str(ev_data.get("start", ""))

        if not data.get("_confirmed"):
            from familiar.core.confirmations import caldav_delete_preview, needs_confirmation

            return needs_confirmation(
                "nextcloud_calendar_delete",
                data,
                caldav_delete_preview(event_title, event_start),
                risk="high",
            )

        event_obj.delete()
        return f"Deleted: {event_title}"

    except Exception as e:
        logger.error(f"CalDAV delete error: {e}")
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


# ---------------------------------------------------------------------------
# CardDAV Contacts Tools
# ---------------------------------------------------------------------------


def _parse_vcard(vcard_data: str) -> dict:
    """Parse a single vCard and return a contact dict."""
    try:
        import vobject
    except ImportError:
        return {}
    try:
        card = vobject.readOne(vcard_data)
        contact = {}
        if hasattr(card, "fn"):
            contact["name"] = str(card.fn.value)
        if hasattr(card, "email"):
            contact["email"] = str(card.email.value)
        if hasattr(card, "tel"):
            contact["phone"] = str(card.tel.value)
        if hasattr(card, "org"):
            val = card.org.value
            contact["organization"] = val[0] if isinstance(val, list) else str(val)
        return contact
    except Exception as e:
        logger.warning(f"vCard parse error: {e}")
        return {}


def nc_list_remote_contacts(data: dict) -> str:
    """List contacts from the CardDAV server."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up, or set NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_TOKEN."

    url = _get_carddav_url()
    auth = _nc_auth()
    try:
        resp = httpx.request(
            "PROPFIND",
            url,
            auth=auth,
            headers={"Depth": "1", "Content-Type": "application/xml"},
            content='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>',
            timeout=REQUEST_TIMEOUT,
        )
        if resp.status_code not in (200, 207):
            return format_http_error(
                "Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud"
            )

        # Parse multistatus to find .vcf hrefs
        root = ET.fromstring(resp.text)
        ns = {"d": "DAV:"}
        hrefs = []
        for response_el in root.findall(".//d:response", ns):
            href_el = response_el.find("d:href", ns)
            if href_el is not None and href_el.text and href_el.text.endswith(".vcf"):
                hrefs.append(href_el.text)

        if not hrefs:
            return "No contacts found on server."

        cfg = _get_nextcloud_config()
        contacts = []
        for href in hrefs[:50]:  # Limit to 50 contacts
            card_url = cfg["url"] + href
            card_resp = httpx.get(card_url, auth=auth, timeout=REQUEST_TIMEOUT)
            if card_resp.status_code == 200:
                c = _parse_vcard(card_resp.text)
                if c:
                    contacts.append(c)

        if not contacts:
            return "No parseable contacts found."

        lines = [f"Contacts ({len(contacts)}):"]
        for c in contacts:
            line = f"  {c.get('name', '(unnamed)')}"
            if c.get("email"):
                line += f" <{c['email']}>"
            if c.get("phone"):
                line += f" {c['phone']}"
            lines.append(line)
        return "\n".join(lines)

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_sync_contacts_in(data: dict) -> str:
    """Pull contacts from CardDAV server into local contacts store."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    url = _get_carddav_url()
    auth = _nc_auth()

    try:
        resp = httpx.request(
            "PROPFIND",
            url,
            auth=auth,
            headers={"Depth": "1", "Content-Type": "application/xml"},
            content='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>',
            timeout=REQUEST_TIMEOUT,
        )
        if resp.status_code not in (200, 207):
            return format_http_error(
                "Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud"
            )

        root = ET.fromstring(resp.text)
        ns = {"d": "DAV:"}
        hrefs = []
        for response_el in root.findall(".//d:response", ns):
            href_el = response_el.find("d:href", ns)
            if href_el is not None and href_el.text and href_el.text.endswith(".vcf"):
                hrefs.append(href_el.text)

        cfg = _get_nextcloud_config()
        imported = 0
        for href in hrefs:
            card_url = cfg["url"] + href
            card_resp = httpx.get(card_url, auth=auth, timeout=REQUEST_TIMEOUT)
            if card_resp.status_code == 200:
                c = _parse_vcard(card_resp.text)
                if c and c.get("name"):
                    try:
                        from familiar.skills.contacts.skill import add_contact

                        add_contact(
                            {
                                "name": c["name"],
                                "email": c.get("email", ""),
                                "phone": c.get("phone", ""),
                                "organization": c.get("organization", ""),
                            }
                        )
                        imported += 1
                    except Exception:
                        pass

        return f"Synced {imported} contacts from Nextcloud to local store."

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_sync_contacts_out(data: dict) -> str:
    """Push local contacts to CardDAV server."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    if not data.get("_confirmed"):
        from familiar.core.confirmations import needs_confirmation, nextcloud_sync_preview

        # Count local contacts
        try:
            from familiar.skills.contacts.skill import list_contacts

            result = list_contacts({})
            count = result.count("\n") if result else 0
        except Exception:
            count = 0
        return needs_confirmation(
            "nextcloud_contacts_sync_out",
            data,
            nextcloud_sync_preview("to Nextcloud", count),
            risk="medium",
        )

    try:
        import vobject
    except ImportError:
        from familiar.core.deps import CALDAV_PACKAGES, ensure_packages

        ok, _ = ensure_packages(CALDAV_PACKAGES)
        if ok:
            import vobject
        else:
            return "❌ Contact sync not available. Check server logs."

    try:
        from familiar.skills.contacts.skill import _load_contacts

        contacts = _load_contacts()
    except Exception:
        return "Could not load local contacts."

    if not contacts:
        return "No local contacts to sync."

    url = _get_carddav_url()
    auth = _nc_auth()
    pushed = 0

    for c in contacts:
        name = c.get("name", "")
        if not name:
            continue
        card = vobject.vCard()
        card.add("fn").value = name
        card.add("n").value = vobject.vcard.Name(
            family=name.split()[-1] if " " in name else name,
            given=name.split()[0] if " " in name else "",
        )
        if c.get("email"):
            card.add("email").value = c["email"]
        if c.get("phone"):
            card.add("tel").value = c["phone"]
        if c.get("organization"):
            card.add("org").value = [c["organization"]]

        slug = name.lower().replace(" ", "_").replace(".", "")
        card_url = url + slug + ".vcf"
        try:
            resp = httpx.put(
                card_url,
                auth=auth,
                content=card.serialize(),
                headers={"Content-Type": "text/vcard; charset=utf-8"},
                timeout=REQUEST_TIMEOUT,
            )
            if resp.status_code in (200, 201, 204):
                pushed += 1
        except httpx.HTTPError:
            pass

    return f"Pushed {pushed} contacts to Nextcloud."


# ---------------------------------------------------------------------------
# WebDAV File Tools
# ---------------------------------------------------------------------------


def _parse_multistatus(xml_text: str) -> list:
    """Parse WebDAV PROPFIND multistatus XML into file dicts."""
    files = []
    try:
        root = ET.fromstring(xml_text)
        ns = {"d": "DAV:"}
        for response_el in root.findall(".//d:response", ns):
            href_el = response_el.find("d:href", ns)
            if href_el is None:
                continue
            href = href_el.text or ""
            props = response_el.find(".//d:prop", ns)
            is_dir = False
            size = 0
            if props is not None:
                rt = props.find("d:resourcetype", ns)
                if rt is not None and rt.find("d:collection", ns) is not None:
                    is_dir = True
                cl = props.find("d:getcontentlength", ns)
                if cl is not None and cl.text:
                    try:
                        size = int(cl.text)
                    except ValueError:
                        pass
            name = href.rstrip("/").rsplit("/", 1)[-1]
            if name:
                files.append({"name": name, "href": href, "is_dir": is_dir, "size": size})
    except ET.ParseError as e:
        logger.warning(f"XML parse error: {e}")
    return files


def nc_list_files(data: dict) -> str:
    """List files in a WebDAV directory."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    path = data.get("path", "/")
    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.request(
            "PROPFIND",
            url,
            auth=auth,
            headers={
                "Depth": "1",
                "Content-Type": "application/xml",
            },
            content='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/><d:getlastmodified/></d:prop></d:propfind>',
            timeout=REQUEST_TIMEOUT,
        )
        if resp.status_code not in (200, 207):
            return format_http_error(
                "Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud"
            )

        files = _parse_multistatus(resp.text)
        # First entry is the directory itself, skip it
        if files:
            files = files[1:]

        if not files:
            return f"Empty directory: {path}"

        lines = [f"Files in {path}:"]
        for f in files:
            icon = "/" if f["is_dir"] else ""
            size_str = ""
            if not f["is_dir"] and f["size"]:
                if f["size"] > 1048576:
                    size_str = f" ({f['size'] / 1048576:.1f} MB)"
                elif f["size"] > 1024:
                    size_str = f" ({f['size'] / 1024:.1f} KB)"
                else:
                    size_str = f" ({f['size']} B)"
            lines.append(f"  {f['name']}{icon}{size_str}")
        return "\n".join(lines)

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_search_files(data: dict) -> str:
    """Search files by name in a directory tree."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    query = data.get("query", "").lower()
    path = data.get("path", "/")
    if not query:
        return "Please provide a search query."

    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.request(
            "PROPFIND",
            url,
            auth=auth,
            headers={"Depth": "infinity", "Content-Type": "application/xml"},
            content='<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/></d:prop></d:propfind>',
            timeout=REQUEST_TIMEOUT,
        )
        if resp.status_code not in (200, 207):
            return format_http_error(
                "Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud"
            )

        files = _parse_multistatus(resp.text)
        matches = [f for f in files if query in f["name"].lower()]

        if not matches:
            return f"No files matching '{query}'."

        lines = [f"Search results for '{query}':"]
        for f in matches[:20]:
            icon = "/" if f["is_dir"] else ""
            lines.append(f"  {f['href']}{icon}")
        if len(matches) > 20:
            lines.append(f"  ... and {len(matches) - 20} more")
        return "\n".join(lines)

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_read_file(data: dict) -> str:
    """Read a text file from WebDAV (truncated at 5000 chars)."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    path = data.get("path", "")
    if not path:
        return "Please provide a file path."

    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.get(url, auth=auth, timeout=REQUEST_TIMEOUT)
        if resp.status_code == 404:
            return f"File not found: {path}"
        if resp.status_code != 200:
            return format_http_error(
                "Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud"
            )

        try:
            text = resp.text
        except UnicodeDecodeError:
            return f"File {path} appears to be binary and cannot be displayed as text."

        if len(text) > 5000:
            return text[:5000] + f"\n\n... (truncated, {len(text)} chars total)"
        return text

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_upload_file(data: dict) -> str:
    """Upload a file to WebDAV."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    path = data.get("path", "")
    content = data.get("content", "")
    if not path:
        return "Please provide a destination path."
    if not content:
        return "Please provide file content."

    if not data.get("_confirmed"):
        from familiar.core.confirmations import needs_confirmation, nextcloud_upload_preview

        filename = path.rsplit("/", 1)[-1] if "/" in path else path
        return needs_confirmation(
            "nextcloud_files_upload",
            data,
            nextcloud_upload_preview(filename, path),
            risk="medium",
        )

    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.put(
            url,
            auth=auth,
            content=content.encode("utf-8"),
            headers={"Content-Type": "application/octet-stream"},
            timeout=REQUEST_TIMEOUT,
        )
        if resp.status_code in (200, 201, 204):
            return f"Uploaded: {path}"
        return format_http_error("Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud")

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_mkdir(data: dict) -> str:
    """Create a directory via WebDAV MKCOL."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    path = data.get("path", "")
    if not path:
        return "Please provide a directory path."

    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.request("MKCOL", url, auth=auth, timeout=REQUEST_TIMEOUT)
        if resp.status_code in (200, 201):
            return f"Created directory: {path}"
        elif resp.status_code == 405:
            return f"Directory already exists: {path}"
        return format_http_error("Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud")

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


def nc_delete_file(data: dict) -> str:
    """Delete a file or directory via WebDAV."""
    if not _nc_configured():
        return "Nextcloud not configured. Run /connect nextcloud to set up."

    path = data.get("path", "")
    if not path:
        return "Please provide a path to delete."

    if not data.get("_confirmed"):
        from familiar.core.confirmations import needs_confirmation, nextcloud_delete_preview

        return needs_confirmation(
            "nextcloud_files_delete",
            data,
            nextcloud_delete_preview(path),
            risk="high",
        )

    url = _get_webdav_url(path)
    auth = _nc_auth()

    try:
        resp = httpx.delete(url, auth=auth, timeout=REQUEST_TIMEOUT)
        if resp.status_code in (200, 204):
            return f"Deleted: {path}"
        elif resp.status_code == 404:
            return f"Not found: {path}"
        return format_http_error("Nextcloud", status_code=resp.status_code, connect_cmd="nextcloud")

    except httpx.HTTPError as e:
        return format_http_error("Nextcloud", exception=e, connect_cmd="nextcloud")


# ---------------------------------------------------------------------------
# Tool definitions
# ---------------------------------------------------------------------------

TOOLS = [
    # CalDAV Calendar
    {
        "name": "nextcloud_calendar_today",
        "description": (
            "Retrieve calendar events from a Nextcloud/CalDAV server for today or a specified date range. "
            "Use this for Nextcloud calendars only — for Google Calendar use calendar_today instead. "
            "Accepts 'today', 'tomorrow', 'this week', or YYYY-MM-DD date strings. "
            "Returns events sorted by start time with titles, times, and locations."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "date": {
                    "type": "string",
                    "description": "Date to check (today, tomorrow, this week, YYYY-MM-DD)",
                    "default": "today",
                },
                "days": {"type": "integer", "description": "Number of days to show", "default": 1},
            },
        },
        "handler": nc_get_events,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_calendar_create",
        "description": (
            "Create a new calendar event on a Nextcloud/CalDAV server. "
            "Use this to schedule meetings or appointments on the user's Nextcloud calendar. "
            "Requires title and start time (YYYY-MM-DD HH:MM); duration defaults to 60 minutes. "
            "Prompts for confirmation before creating the event."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Event title"},
                "start": {"type": "string", "description": "Start date/time (YYYY-MM-DD HH:MM)"},
                "duration": {
                    "type": "integer",
                    "description": "Duration in minutes",
                    "default": 60,
                },
                "location": {"type": "string"},
                "description": {"type": "string"},
                "attendees": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "List of attendee email addresses",
                },
            },
            "required": ["title", "start"],
        },
        "handler": nc_create_event,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_calendar_check",
        "description": (
            "Check whether a specific time slot is free on the Nextcloud/CalDAV calendar. "
            "Use this before creating events to avoid scheduling conflicts. "
            "Accepts a date, time (HH:MM), and duration in minutes. "
            "Returns availability status or a list of conflicting event titles."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "date": {"type": "string", "description": "Date to check"},
                "time": {"type": "string", "description": "Time to check (HH:MM)"},
                "duration": {
                    "type": "integer",
                    "description": "Duration in minutes",
                    "default": 60,
                },
            },
            "required": ["date"],
        },
        "handler": nc_check_availability,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_calendar_free",
        "description": (
            "Find open time slots on a given day by scanning the Nextcloud/CalDAV calendar for gaps. "
            "Use this when the user needs to find a free window for a meeting or task. "
            "Configurable business hours (start_hour/end_hour) and minimum slot duration. "
            "Returns up to 5 available time ranges within the specified hours."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "date": {"type": "string", "default": "today"},
                "duration": {"type": "integer", "description": "Minutes needed", "default": 60},
                "start_hour": {
                    "type": "integer",
                    "description": "Earliest hour (24h)",
                    "default": 9,
                },
                "end_hour": {"type": "integer", "description": "Latest hour (24h)", "default": 17},
            },
        },
        "handler": nc_find_free_time,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_calendar_delete",
        "description": (
            "Delete a calendar event from Nextcloud/CalDAV by searching for its title on a given date. "
            "Use this to remove cancelled or rescheduled events. "
            "Matches the first event whose title contains the search string (case-insensitive). "
            "Prompts for confirmation before deleting since this action is irreversible."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "search": {"type": "string", "description": "Event title to search for"},
                "date": {"type": "string", "default": "today"},
            },
            "required": ["search"],
        },
        "handler": nc_delete_event,
        "category": "nextcloud",
    },
    # CardDAV Contacts
    {
        "name": "nextcloud_contacts_list",
        "description": (
            "List contacts stored on the Nextcloud/CardDAV address book server. "
            "Use this to browse or look up contact details like email and phone. "
            "Fetches up to 50 contacts with name, email, and phone fields. "
            "Requires NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN environment variables."
        ),
        "input_schema": {"type": "object", "properties": {}},
        "handler": nc_list_remote_contacts,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_contacts_sync_in",
        "description": (
            "Pull contacts from the Nextcloud/CardDAV server into the local contacts store. "
            "Use this to import cloud contacts for offline access or local searching. "
            "Performs an additive merge — existing local contacts are not overwritten. "
            "Returns the count of successfully imported contacts."
        ),
        "input_schema": {"type": "object", "properties": {}},
        "handler": nc_sync_contacts_in,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_contacts_sync_out",
        "description": (
            "Push local contacts to the Nextcloud/CardDAV server as vCard entries. "
            "Use this to back up or sync local contacts to the cloud. "
            "Performs an additive merge and prompts for confirmation before uploading. "
            "Returns the count of successfully pushed contacts."
        ),
        "input_schema": {"type": "object", "properties": {}},
        "handler": nc_sync_contacts_out,
        "category": "nextcloud",
    },
    # WebDAV Files
    {
        "name": "nextcloud_files_list",
        "description": (
            "List files and directories in a Nextcloud/WebDAV cloud storage folder. "
            "Use this for cloud file browsing — for local filesystem use run_shell with ls instead. "
            "Shows file names, sizes (KB/MB), and directory indicators. "
            "Defaults to the root directory if no path is specified."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Directory path", "default": "/"},
            },
        },
        "handler": nc_list_files,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_files_search",
        "description": (
            "Search for files by name across a Nextcloud/WebDAV directory tree. "
            "Use this when the user needs to locate a file but does not know its exact path. "
            "Performs a case-insensitive substring match and returns up to 20 results. "
            "Searches recursively from the specified path (default: root)."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search term"},
                "path": {"type": "string", "description": "Directory to search in", "default": "/"},
            },
            "required": ["query"],
        },
        "handler": nc_search_files,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_files_read",
        "description": (
            "Read the contents of a text file from Nextcloud/WebDAV cloud storage. "
            "Use this for cloud-hosted files — for local files use read_document instead. "
            "Output is truncated at 5000 characters for large files. "
            "Binary files cannot be displayed and will return a format warning."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path to read"},
            },
            "required": ["path"],
        },
        "handler": nc_read_file,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_files_upload",
        "description": (
            "Upload a file with text content to Nextcloud/WebDAV cloud storage. "
            "Use this to create or overwrite files on the cloud server. "
            "Requires a destination path and content string; prompts for confirmation before uploading. "
            "Returns success with the file path or an HTTP error with recovery hints."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Destination path on server"},
                "content": {"type": "string", "description": "File content to upload"},
            },
            "required": ["path", "content"],
        },
        "handler": nc_upload_file,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_files_mkdir",
        "description": (
            "Create a new directory on the Nextcloud/WebDAV cloud server via MKCOL. "
            "Use this to organize cloud storage before uploading files. "
            "Returns success confirmation or a notice if the directory already exists. "
            "Requires NEXTCLOUD_URL, NEXTCLOUD_USER, and NEXTCLOUD_TOKEN environment variables."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Directory path to create"},
            },
            "required": ["path"],
        },
        "handler": nc_mkdir,
        "category": "nextcloud",
    },
    {
        "name": "nextcloud_files_delete",
        "description": (
            "Delete a file or directory from Nextcloud/WebDAV cloud storage. "
            "Use this to remove unwanted cloud files — this action is irreversible. "
            "Prompts for user confirmation before performing the deletion. "
            "Returns success with the deleted path or a 404 if the path does not exist."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "Path to delete"},
            },
            "required": ["path"],
        },
        "handler": nc_delete_file,
        "category": "nextcloud",
    },
]
