# tools/watchlist_tools.py — Collector-style watchlist: monitor URLs for changes.
# Stores watchlist and baselines under workspace/memory/ using safe_resolve_path + atomic writes.
from __future__ import annotations

import fcntl
import hashlib
import json
import os
import re
import tempfile
import uuid
from datetime import datetime, timezone
from urllib.parse import urlparse

from langchain_core.tools import tool

from tools._logging import _debug_log, _emit_input_status
from tools.job_stream_schema import close_job_stream, error_job_stream, make_capabilities, metric_def, open_job_stream
from utils.llm_token_normalize import normalize_hex_id_token

# Keywords that indicate high-significance changes worth an URGENT prefix
_URGENT_KEYWORDS = frozenset({
    "ceo", "cto", "coo", "cfo", "acquired", "acquisition", "merger", "funding",
    "raised", "ipo", "bankrupt", "bankruptcy", "shutdown", "launched", "launch",
    "recalled", "recall", "discontinued", "breach", "hacked", "vulnerability",
    "critical", "emergency", "breaking",
})

_WHITESPACE_RE = re.compile(r"\s+")


def _get_workspace() -> str:
    from core import get_workspace_path
    return get_workspace_path()


def _get_watchlist_path() -> tuple[str | None, str]:
    from utils.path_safety import safe_resolve_path
    ws = _get_workspace()
    return safe_resolve_path(ws, "memory/watchlist.json", allow_write=True, create_parents=True)


def _get_baselines_path() -> tuple[str | None, str]:
    from utils.path_safety import safe_resolve_path
    ws = _get_workspace()
    return safe_resolve_path(ws, "memory/watch_baselines.json", allow_write=True, create_parents=True)


def _read_json_file(path: str, default):
    """Read a JSON file, returning default if missing or parse error."""
    try:
        if not os.path.isfile(path):
            return default
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return default


def _atomic_write_json(path: str, data) -> None:
    """Write data as JSON to path atomically (write temp, rename) with file lock."""
    parent = os.path.dirname(path)
    os.makedirs(parent, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(dir=parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            fcntl.flock(f, fcntl.LOCK_EX)
            json.dump(data, f, indent=2, ensure_ascii=False)
            f.flush()
            os.fsync(f.fileno())
            fcntl.flock(f, fcntl.LOCK_UN)
        os.replace(tmp_path, path)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _normalize_content(content: str) -> str:
    """Normalize whitespace for stable hashing."""
    return _WHITESPACE_RE.sub(" ", (content or "").strip())


def _hash_content(content: str) -> str:
    return hashlib.sha256(_normalize_content(content).encode("utf-8")).hexdigest()


def _normalize_watch_id(watch_id: str) -> str:
    return normalize_hex_id_token((watch_id or "").strip(), allowed_lengths=(8,))


def _normalize_baseline_keys(baselines: dict) -> tuple[dict, bool]:
    changed = False
    normalized: dict = {}
    for key, value in (baselines or {}).items():
        nk = _normalize_watch_id(str(key))
        if nk != str(key):
            changed = True
        normalized[nk] = value
    return normalized, changed


def _check_urgent(content: str) -> bool:
    """Return True if content contains any high-significance keywords."""
    lower = content.lower()
    return any(kw in lower for kw in _URGENT_KEYWORDS)


@tool
def add_watch_target(target_url: str, label: str = "") -> str:
    """Add a URL to the watchlist for periodic change monitoring.

    Args:
        target_url: The HTTP/HTTPS URL to watch.
        label: Optional human-readable label (e.g. "Apple newsroom").

    Returns a confirmation string with the assigned watch ID, or an error message.
    """
    _emit_input_status(f"add_watch_target: {target_url[:80]}")
    try:
        target_url = re.sub(r"\s+", "", target_url or "")
        parsed = urlparse(target_url)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            return f"ERROR: URL must use http or https and include a hostname. Got: {target_url!r}"
    except Exception as e:
        return f"ERROR: Invalid URL: {e}"

    wl_path, err = _get_watchlist_path()
    if err:
        return f"ERROR: Cannot resolve watchlist path: {err}"
    bl_path, err2 = _get_baselines_path()
    if err2:
        return f"ERROR: Cannot resolve baselines path: {err2}"

    watchlist: list[dict] = _read_json_file(wl_path, [])
    if not isinstance(watchlist, list):
        watchlist = []

    # Deduplicate by URL
    for entry in watchlist:
        if entry.get("target") == target_url.strip():
            return (
                f"Already watching: {target_url}\n"
                f"  ID: {entry['id']}\n"
                f"  Label: {entry.get('label') or '(none)'}\n"
                f"  Added: {entry.get('created_at', '?')}"
            )

    watch_id = str(uuid.uuid4())[:8]
    entry = {
        "id": watch_id,
        "target": target_url.strip(),
        "label": (label or "").strip(),
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
    watchlist.append(entry)
    _atomic_write_json(wl_path, watchlist)

    # Ensure a blank baseline exists for this ID
    baselines: dict = _read_json_file(bl_path, {})
    if not isinstance(baselines, dict):
        baselines = {}
    if watch_id not in baselines:
        baselines[watch_id] = {"content_hash": "", "last_checked_ts": ""}
        _atomic_write_json(bl_path, baselines)

    _debug_log(f"[watchlist] Added: {watch_id} → {target_url}")
    return (
        f"Added to watchlist.\n"
        f"  ID: {watch_id}\n"
        f"  URL: {target_url}\n"
        f"  Label: {entry['label'] or '(none)'}\n"
        f"  No baseline yet — will be set on first collector run."
    )


@tool
def list_watch_targets() -> str:
    """List all URLs currently in the watchlist.

    Returns a formatted list of watch targets with their IDs, labels, and when they were added,
    or a message if the watchlist is empty.
    """
    _emit_input_status("list_watch_targets")
    wl_path, err = _get_watchlist_path()
    if err:
        return f"ERROR: Cannot resolve watchlist path: {err}"

    watchlist: list[dict] = _read_json_file(wl_path, [])
    if not isinstance(watchlist, list):
        watchlist = []
    if not watchlist:
        return "Watchlist is empty. Use add_watch_target(url) to start monitoring a URL."

    bl_path, _ = _get_baselines_path()
    baselines: dict = _read_json_file(bl_path, {}) if bl_path else {}
    if not isinstance(baselines, dict):
        baselines = {}

    lines = [f"Watchlist ({len(watchlist)} target(s)):"]
    for entry in watchlist:
        wid = entry.get("id", "?")
        url = entry.get("target", "?")
        label = entry.get("label") or ""
        added = entry.get("created_at", "?")
        bl = baselines.get(wid, {})
        last_checked = bl.get("last_checked_ts") or "never"
        has_baseline = bool(bl.get("content_hash"))
        line = f"  [{wid}] {url}"
        if label:
            line += f" — {label}"
        line += f"\n       Added: {added} | Last checked: {last_checked} | Baseline: {'set' if has_baseline else 'not set yet'}"
        lines.append(line)
    return "\n".join(lines)


@tool
def remove_watch_target(watch_id: str) -> str:
    """Remove a URL from the watchlist by its ID.

    Args:
        watch_id: The watch target ID (from list_watch_targets or add_watch_target).

    Returns a confirmation or an error if the ID was not found.
    """
    watch_id = _normalize_watch_id(watch_id)
    _emit_input_status(f"remove_watch_target: {watch_id}")
    wl_path, err = _get_watchlist_path()
    if err:
        return f"ERROR: Cannot resolve watchlist path: {err}"
    bl_path, err2 = _get_baselines_path()
    if err2:
        return f"ERROR: Cannot resolve baselines path: {err2}"

    watchlist: list[dict] = _read_json_file(wl_path, [])
    if not isinstance(watchlist, list):
        watchlist = []

    original_len = len(watchlist)
    removed_entry = None
    new_list = []
    for entry in watchlist:
        if _normalize_watch_id(str(entry.get("id", ""))) == watch_id:
            removed_entry = entry
        else:
            new_list.append(entry)

    if removed_entry is None:
        ids = [e.get("id", "?") for e in watchlist]
        return f"ID not found: {watch_id!r}. Current IDs: {ids}"

    _atomic_write_json(wl_path, new_list)

    # Remove baseline entry too
    baselines: dict = _read_json_file(bl_path, {})
    if isinstance(baselines, dict):
        baselines, _ = _normalize_baseline_keys(baselines)
    if isinstance(baselines, dict) and watch_id in baselines:
        del baselines[watch_id]
        _atomic_write_json(bl_path, baselines)

    url = removed_entry.get("target", "?")
    label = removed_entry.get("label") or ""
    _debug_log(f"[watchlist] Removed: {watch_id} → {url}")
    return f"Removed from watchlist: [{watch_id}] {url}" + (f" ({label})" if label else "")


@tool
def compare_watch_baseline(watch_id: str, current_content: str) -> str:
    """Compare current page content against the stored baseline for a watch target.

    Call this after read_webpage for each watchlist entry during a collector run.
    If content has changed (or no baseline exists), updates the stored baseline and returns
    a CHANGED: or URGENT: string. If unchanged, returns UNCHANGED.

    Args:
        watch_id: The watch target ID.
        current_content: The current page content (from read_webpage).

    Returns:
        - "UNCHANGED" if the hash matches the stored baseline.
        - "CHANGED: <short summary>" if content changed (baseline updated).
        - "URGENT: CHANGED: <short summary>" if changed AND content contains high-significance keywords.
        - "ERROR: ..." on failure.
    """
    watch_id = _normalize_watch_id(watch_id)
    _emit_input_status(f"compare_watch_baseline: {watch_id}")
    stream_id = f"collector_watch:{watch_id}:{int(datetime.now(timezone.utc).timestamp() * 1000)}"
    open_job_stream(
        plugin_id="collector_watch",
        plugin_name="Watchlist Collector",
        title="Compare Watch Baseline",
        job_type="compare_watch_baseline",
        stream_id=stream_id,
        capabilities=make_capabilities(metrics=True, phases=True, batch_items=True),
        metric_schema=[
            metric_def("content_length_chars", "Content Length", unit="chars", format="number"),
            metric_def("changed_flag", "Changed", format="number"),
            metric_def("urgent_flag", "Urgent", format="number"),
        ],
        metadata={"watch_id": watch_id},
    )
    try:
        bl_path, err = _get_baselines_path()
        if err:
            msg = f"ERROR: Cannot resolve baselines path: {err}"
            error_job_stream(
                plugin_id="collector_watch",
                plugin_name="Watchlist Collector",
                stream_id=stream_id,
                phase="setup",
                message=msg,
            )
            return msg

        baselines: dict = _read_json_file(bl_path, {})
        if not isinstance(baselines, dict):
            baselines = {}
        else:
            baselines, migrated = _normalize_baseline_keys(baselines)
            if migrated:
                _atomic_write_json(bl_path, baselines)

        current_hash = _hash_content(current_content)
        stored = baselines.get(watch_id, {})
        stored_hash = stored.get("content_hash", "")

        if stored_hash and stored_hash == current_hash:
            close_job_stream(
                plugin_id="collector_watch",
                plugin_name="Watchlist Collector",
                stream_id=stream_id,
                status="completed",
                message="No change detected.",
                metrics={"content_length_chars": float(len(current_content)), "changed_flag": 0.0, "urgent_flag": 0.0},
            )
            return "UNCHANGED"

        is_first = not stored_hash
        baselines[watch_id] = {
            "content_hash": current_hash,
            "last_checked_ts": datetime.now(timezone.utc).isoformat(),
        }
        try:
            _atomic_write_json(bl_path, baselines)
        except Exception as e:
            msg = f"ERROR: Failed to update baseline: {e}"
            error_job_stream(
                plugin_id="collector_watch",
                plugin_name="Watchlist Collector",
                stream_id=stream_id,
                phase="write_baseline",
                message=msg,
            )
            return msg

        if is_first:
            _debug_log(f"[watchlist] Baseline set for {watch_id}")
            close_job_stream(
                plugin_id="collector_watch",
                plugin_name="Watchlist Collector",
                stream_id=stream_id,
                status="completed",
                message="Baseline set (first check).",
                metrics={"content_length_chars": float(len(current_content)), "changed_flag": 1.0, "urgent_flag": 0.0},
            )
            return f"CHANGED: Baseline set (first check). Content length: {len(current_content)} chars."

        snippet = _normalize_content(current_content)[:150]
        summary = f"Content updated. Preview: {snippet!r}" if snippet else "Content updated."
        result = f"CHANGED: {summary}"
        urgent = _check_urgent(current_content)
        if urgent:
            result = f"URGENT: {result}"
        _debug_log(f"[watchlist] {result[:100]} (id={watch_id})")
        close_job_stream(
            plugin_id="collector_watch",
            plugin_name="Watchlist Collector",
            stream_id=stream_id,
            status="completed",
            message="Change detected.",
            metrics={
                "content_length_chars": float(len(current_content)),
                "changed_flag": 1.0,
                "urgent_flag": 1.0 if urgent else 0.0,
            },
            extra={"result": result[:500]},
        )
        return result
    finally:
        pass
