"""Core check engine for Pulse.

Implements the four check types:
- file_freshness: Is a file modified within max_age?
- json_key_changed: Has a JSON key's value changed since last check?
- log_entry: Does a regex pattern appear in recent log lines?
- process_output: Does a command succeed or produce expected output?
"""

import json
import os
import re
import subprocess
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


@dataclass
class CheckResult:
    """Result of a single check."""
    check_type: str
    passed: bool
    message: str
    last_activity_seconds: Optional[float] = None  # seconds ago

    def to_dict(self) -> Dict[str, Any]:
        d: Dict[str, Any] = {
            "check_type": self.check_type,
            "passed": self.passed,
            "message": self.message,
        }
        if self.last_activity_seconds is not None:
            d["last_activity_seconds"] = self.last_activity_seconds
        return d


@dataclass
class VitalResult:
    """Result for a named vital (group of checks)."""
    name: str
    checks: List[CheckResult] = field(default_factory=list)

    @property
    def passed(self) -> bool:
        return all(c.passed for c in self.checks)

    @property
    def failed_count(self) -> int:
        return sum(1 for c in self.checks if not c.passed)

    @property
    def passed_count(self) -> int:
        return sum(1 for c in self.checks if c.passed)

    @property
    def total_count(self) -> int:
        return len(self.checks)

    @property
    def last_activity_seconds(self) -> Optional[float]:
        """Most recent activity across all checks."""
        activities = [c.last_activity_seconds for c in self.checks
                      if c.last_activity_seconds is not None]
        return min(activities) if activities else None

    @property
    def severity(self) -> str:
        """OK, WARN, or CRIT."""
        if self.passed:
            return "OK"
        if self.passed_count > 0:
            return "WARN"
        return "CRIT"

    def to_dict(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "severity": self.severity,
            "passed": self.passed,
            "passed_count": self.passed_count,
            "failed_count": self.failed_count,
            "total_count": self.total_count,
            "last_activity_seconds": self.last_activity_seconds,
            "checks": [c.to_dict() for c in self.checks],
        }


def parse_duration(s: str) -> float:
    """Parse a human duration string to seconds.

    Supports: 30s, 5m, 2h, 1d, 1w
    Combined forms: 1d12h, 2h30m
    """
    if not s:
        raise ValueError("Empty duration string")

    s = s.strip().lower()
    total = 0.0
    pattern = re.compile(r"(\d+(?:\.\d+)?)\s*([smhdw])")
    matches = pattern.findall(s)

    if not matches:
        raise ValueError(f"Invalid duration format: '{s}'. Use e.g. '2h', '30m', '1d'")

    multipliers = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
    for value, unit in matches:
        total += float(value) * multipliers[unit]

    return total


def format_duration(seconds: Optional[float]) -> str:
    """Format seconds into human-readable duration."""
    if seconds is None:
        return "unknown"
    if seconds < 0:
        return "in the future"

    seconds = abs(seconds)
    if seconds < 60:
        return f"{int(seconds)}s ago"
    if seconds < 3600:
        return f"{int(seconds / 60)}m ago"
    if seconds < 86400:
        h = int(seconds / 3600)
        m = int((seconds % 3600) / 60)
        if m:
            return f"{h}h{m}m ago"
        return f"{h}h ago"
    if seconds < 604800:
        d = int(seconds / 86400)
        h = int((seconds % 86400) / 3600)
        if h:
            return f"{d}d{h}h ago"
        return f"{d}d ago"
    w = int(seconds / 604800)
    return f"{w}w ago"


class StateStore:
    """Persistent state for checks that need to track previous values."""

    def __init__(self, path: str = ".pulse_state.json"):
        self.path = path
        self._data: Dict[str, Any] = {}
        self._load()

    def _load(self) -> None:
        if os.path.exists(self.path):
            try:
                with open(self.path, "r", encoding="utf-8") as f:
                    self._data = json.load(f)
            except (json.JSONDecodeError, IOError):
                self._data = {}

    def save(self) -> None:
        with open(self.path, "w", encoding="utf-8") as f:
            json.dump(self._data, f, indent=2, default=str)

    def get(self, key: str) -> Any:
        return self._data.get(key)

    def set(self, key: str, value: Any) -> None:
        self._data[key] = value


class Checker:
    """Executes checks against vital sign definitions."""

    def __init__(self, base_dir: str = ".", state_path: str = ".pulse_state.json"):
        self.base_dir = os.path.abspath(base_dir)
        self.state = StateStore(state_path)

    def _resolve_path(self, path: str) -> str:
        """Resolve a path relative to base_dir."""
        if os.path.isabs(path):
            return path
        return os.path.join(self.base_dir, path)

    def check_file_freshness(self, check: Dict[str, Any]) -> CheckResult:
        """Check if a file was modified within max_age."""
        path = self._resolve_path(check["path"])
        max_age = parse_duration(str(check["max_age"]))

        if not os.path.exists(path):
            return CheckResult(
                check_type="file_freshness",
                passed=False,
                message=f"File not found: {check['path']}",
            )

        mtime = os.path.getmtime(path)
        age = time.time() - mtime

        if age <= max_age:
            return CheckResult(
                check_type="file_freshness",
                passed=True,
                message=f"{check['path']} updated {format_duration(age)}",
                last_activity_seconds=age,
            )
        else:
            return CheckResult(
                check_type="file_freshness",
                passed=False,
                message=f"{check['path']} last updated {format_duration(age)} (max: {check['max_age']})",
                last_activity_seconds=age,
            )

    def check_json_key_changed(self, check: Dict[str, Any]) -> CheckResult:
        """Check if a JSON key's value has changed since last check."""
        path = self._resolve_path(check["path"])
        key = check["key"]
        max_age = parse_duration(str(check["max_age"]))
        state_key = f"json_key:{check['path']}:{key}"

        if not os.path.exists(path):
            return CheckResult(
                check_type="json_key_changed",
                passed=False,
                message=f"File not found: {check['path']}",
            )

        try:
            with open(path, "r", encoding="utf-8") as f:
                data = json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            return CheckResult(
                check_type="json_key_changed",
                passed=False,
                message=f"Cannot read JSON: {e}",
            )

        # Navigate dotted keys
        current = data
        for part in key.split("."):
            if isinstance(current, dict) and part in current:
                current = current[part]
            else:
                return CheckResult(
                    check_type="json_key_changed",
                    passed=False,
                    message=f"Key '{key}' not found in {check['path']}",
                )

        current_value = current
        previous = self.state.get(state_key)
        now = time.time()

        if previous is None:
            # First time seeing this key — record it, report OK
            self.state.set(state_key, {"value": current_value, "changed_at": now})
            return CheckResult(
                check_type="json_key_changed",
                passed=True,
                message=f"'{key}' = {json.dumps(current_value)} (first observation)",
                last_activity_seconds=0,
            )

        prev_value = previous.get("value")
        changed_at = previous.get("changed_at", now)

        if current_value != prev_value:
            # Value changed — update state
            self.state.set(state_key, {"value": current_value, "changed_at": now})
            return CheckResult(
                check_type="json_key_changed",
                passed=True,
                message=f"'{key}' changed: {json.dumps(prev_value)} -> {json.dumps(current_value)}",
                last_activity_seconds=0,
            )

        # Value unchanged — check how long
        age = now - changed_at
        if age <= max_age:
            return CheckResult(
                check_type="json_key_changed",
                passed=True,
                message=f"'{key}' unchanged but within threshold ({format_duration(age)})",
                last_activity_seconds=age,
            )
        else:
            return CheckResult(
                check_type="json_key_changed",
                passed=False,
                message=f"'{key}' has not changed for {format_duration(age)} (max: {check['max_age']})",
                last_activity_seconds=age,
            )

    def check_log_entry(self, check: Dict[str, Any]) -> CheckResult:
        """Check if a regex pattern appears in recent log entries."""
        path = self._resolve_path(check["path"])
        pattern = check["pattern"]
        max_age = parse_duration(str(check["max_age"]))

        if not os.path.exists(path):
            return CheckResult(
                check_type="log_entry",
                passed=False,
                message=f"Log file not found: {check['path']}",
            )

        try:
            regex = re.compile(pattern)
        except re.error as e:
            return CheckResult(
                check_type="log_entry",
                passed=False,
                message=f"Invalid regex pattern '{pattern}': {e}",
            )

        # Read the file from the end (last 10000 lines max for efficiency)
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                lines = f.readlines()
        except IOError as e:
            return CheckResult(
                check_type="log_entry",
                passed=False,
                message=f"Cannot read log file: {e}",
            )

        tail = lines[-10000:] if len(lines) > 10000 else lines

        # Check file mtime as a proxy for recency
        file_mtime = os.path.getmtime(path)
        file_age = time.time() - file_mtime

        # Search from end for the pattern
        found = False
        found_line = ""
        for line in reversed(tail):
            if regex.search(line):
                found = True
                found_line = line.strip()
                break

        if not found:
            return CheckResult(
                check_type="log_entry",
                passed=False,
                message=f'"{pattern}" not found in {check["path"]}',
                last_activity_seconds=None,
            )

        # We found the pattern. Try to extract a timestamp from the line.
        # Common formats: ISO 8601, or use file mtime as fallback
        timestamp = _extract_timestamp(found_line)
        if timestamp is not None:
            age = time.time() - timestamp
        else:
            # Fallback: use file modification time
            age = file_age

        if age <= max_age:
            return CheckResult(
                check_type="log_entry",
                passed=True,
                message=f'"{pattern}" found {format_duration(age)}',
                last_activity_seconds=age,
            )
        else:
            return CheckResult(
                check_type="log_entry",
                passed=False,
                message=f'"{pattern}" last seen {format_duration(age)} (max: {check["max_age"]})',
                last_activity_seconds=age,
            )

    def check_process_output(self, check: Dict[str, Any]) -> CheckResult:
        """Run a command and check its exit code or output."""
        command = check["command"]
        expected_exit = check.get("exit_code", 0)
        expected_output = check.get("output_pattern")
        timeout = check.get("timeout", 30)

        try:
            result = subprocess.run(
                command,
                shell=True,
                capture_output=True,
                text=True,
                timeout=int(timeout),
                cwd=self.base_dir,
            )
        except subprocess.TimeoutExpired:
            return CheckResult(
                check_type="process_output",
                passed=False,
                message=f"Command timed out after {timeout}s: {command}",
            )
        except OSError as e:
            return CheckResult(
                check_type="process_output",
                passed=False,
                message=f"Command failed to execute: {e}",
            )

        exit_ok = result.returncode == expected_exit

        if expected_output:
            try:
                output_ok = bool(re.search(expected_output, result.stdout))
            except re.error as e:
                return CheckResult(
                    check_type="process_output",
                    passed=False,
                    message=f"Invalid output_pattern '{expected_output}': {e}",
                )
        else:
            output_ok = True

        passed = exit_ok and output_ok

        if passed:
            msg = f"Command succeeded: {command}"
        elif not exit_ok:
            msg = f"Command exit code {result.returncode} (expected {expected_exit}): {command}"
        else:
            msg = f"Output did not match '{expected_output}': {command}"

        return CheckResult(
            check_type="process_output",
            passed=passed,
            message=msg,
            last_activity_seconds=0 if passed else None,
        )

    def run_check(self, check: Dict[str, Any]) -> CheckResult:
        """Dispatch a check by type."""
        ctype = check.get("type", "")
        dispatch = {
            "file_freshness": self.check_file_freshness,
            "json_key_changed": self.check_json_key_changed,
            "log_entry": self.check_log_entry,
            "process_output": self.check_process_output,
        }
        handler = dispatch.get(ctype)
        if handler is None:
            return CheckResult(
                check_type=ctype,
                passed=False,
                message=f"Unknown check type: {ctype}",
            )
        return handler(check)

    def run_vital(self, vital: Dict[str, Any]) -> VitalResult:
        """Run all checks for a vital."""
        result = VitalResult(name=vital.get("name", "unnamed"))
        for check in vital.get("checks", []):
            result.checks.append(self.run_check(check))
        return result

    def run_all(self, config: Dict[str, Any], name_filter: Optional[str] = None) -> List[VitalResult]:
        """Run all vitals from config, optionally filtering by name."""
        results = []
        for vital in config.get("vitals", []):
            if name_filter and vital.get("name") != name_filter:
                continue
            result = self.run_vital(vital)
            results.append(result)
        self.state.save()
        return results


def _extract_timestamp(line: str) -> Optional[float]:
    """Try to extract a Unix timestamp from a log line.

    Supports common formats:
    - ISO 8601: 2026-03-22T14:30:00
    - Date + time: 2026-03-22 14:30:00
    - Epoch prefix: 1711100000
    """
    import datetime

    # ISO 8601 or similar datetime
    m = re.search(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", line)
    if m:
        dt_str = m.group(1).replace("T", " ")
        try:
            dt = datetime.datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S")
            return dt.timestamp()
        except ValueError:
            pass

    # Unix epoch (10 or 13 digits)
    m = re.search(r"\b(1\d{9}(?:\d{3})?)\b", line)
    if m:
        epoch = float(m.group(1))
        if epoch > 1e12:
            epoch /= 1000  # milliseconds
        return epoch

    return None
