"""Workspace-level persistent memory for AiCippy.

Stores per-workspace metadata, project study results, learned patterns,
and conversation insights at
``~/.aicippy/workspaces/{workspace_hash}/workspace_memory.json``.

The ``workspace_hash`` is a SHA-256 digest of the absolute CWD path,
making it safe for filesystem use regardless of path characters.

Thread-safe via ``threading.Lock`` on all mutating operations.
"""

from __future__ import annotations

import hashlib
import json
import re
import threading
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Final

from aicippy.config import get_settings
from aicippy.utils.logging import get_logger

logger = get_logger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

WORKSPACES_DIR_NAME: Final[str] = "workspaces"
WORKSPACE_MEMORY_FILE: Final[str] = "workspace_memory.json"
MAX_LEARNED_PATTERNS: Final[int] = 200
MAX_CONVERSATION_INSIGHTS: Final[int] = 500
RESCAN_INTERVAL_HOURS: Final[int] = 24
RESCAN_MESSAGE_INTERVAL: Final[int] = 50
MAX_DIR_DEPTH: Final[int] = 3
MAX_DIR_ENTRIES: Final[int] = 500
MAX_KEY_FILES: Final[int] = 30

# Project markers for auto-detection
_PROJECT_MARKERS: Final[dict[str, str]] = {
    "package.json": "node",
    "pyproject.toml": "python",
    "setup.py": "python",
    "Cargo.toml": "rust",
    "go.mod": "go",
    "pom.xml": "java",
    "build.gradle": "java",
    "pubspec.yaml": "dart",
    "Gemfile": "ruby",
    "composer.json": "php",
    "Makefile": "c/cpp",
    "CMakeLists.txt": "c/cpp",
    "cdk.json": "aws-cdk",
    "serverless.yml": "serverless",
    "Dockerfile": "docker",
    "docker-compose.yml": "docker-compose",
    ".terraform": "terraform",
}

# Key files worth noting in a project study
_KEY_FILE_NAMES: Final[set[str]] = {
    "package.json",
    "pyproject.toml",
    "setup.py",
    "setup.cfg",
    "Cargo.toml",
    "go.mod",
    "pom.xml",
    "build.gradle",
    "pubspec.yaml",
    "Gemfile",
    "composer.json",
    "Makefile",
    "CMakeLists.txt",
    "Dockerfile",
    "docker-compose.yml",
    "docker-compose.yaml",
    ".env.example",
    "README.md",
    "CLAUDE.md",
    ".aicippy.md",
    "tsconfig.json",
    "tox.ini",
    "ruff.toml",
    ".eslintrc.json",
    ".prettierrc",
    "jest.config.js",
    "vitest.config.ts",
    "tailwind.config.js",
    "vite.config.ts",
    "next.config.js",
}

# Tech stack indicators (filename patterns -> tech name)
_TECH_INDICATORS: Final[dict[str, str]] = {
    "tailwind.config": "tailwindcss",
    "vite.config": "vite",
    "next.config": "nextjs",
    "nuxt.config": "nuxt",
    "angular.json": "angular",
    "tsconfig.json": "typescript",
    ".eslintrc": "eslint",
    "jest.config": "jest",
    "vitest.config": "vitest",
    "prisma": "prisma",
    "drizzle.config": "drizzle",
    ".github/workflows": "github-actions",
    ".gitlab-ci.yml": "gitlab-ci",
    "Jenkinsfile": "jenkins",
    "serverless.yml": "serverless",
    "cdk.json": "aws-cdk",
    "terraform": "terraform",
}

# Insight extraction patterns (compiled once)
_FILE_PATH_PATTERN: Final[re.Pattern[str]] = re.compile(
    r"(?:^|\s)((?:src|lib|app|tests?|config|scripts?|utils?)"
    r"/[\w./-]+\.\w+)",
)
_BOLD_FILE_PATTERN: Final[re.Pattern[str]] = re.compile(
    r"\*\*File:\s*(.+?)\*\*",
)
_ARCHITECTURE_KEYWORDS: Final[list[str]] = [
    "should use",
    "pattern is",
    "convention is",
    "best practice",
    "architecture",
    "design decision",
    "we decided",
    "approach is",
]
_ERROR_SOLUTION_PATTERN: Final[re.Pattern[str]] = re.compile(
    r"(?:fix|solution|resolve|solved|workaround)[:\s]",
    re.IGNORECASE,
)
_CONFIG_VALUE_PATTERN: Final[re.Pattern[str]] = re.compile(
    r"(?:set|configure|config|setting)[:\s]+(\S+)\s*(?:=|to)\s*(\S+)",
    re.IGNORECASE,
)


def _workspace_hash(workspace_path: str) -> str:
    """Compute SHA-256 hash of an absolute workspace path."""
    return hashlib.sha256(
        workspace_path.encode("utf-8"),
    ).hexdigest()[:16]


def _count_files_recursive(root: Path) -> int:
    """Count files under *root* without reading contents.

    Skips hidden directories, node_modules, __pycache__, .git,
    and venv directories for speed.
    """
    skip_dirs: Final[set[str]] = {
        ".git",
        "node_modules",
        "__pycache__",
        ".venv",
        "venv",
        ".tox",
        ".mypy_cache",
        ".ruff_cache",
        "dist",
        "build",
        ".next",
        ".nuxt",
        "target",
        "aicippy-ref",
    }
    count = 0
    try:
        for entry in root.iterdir():
            if entry.name.startswith(".") and entry.is_dir() and entry.name not in (".github",):
                continue
            if entry.name in skip_dirs:
                continue
            if entry.is_file():
                count += 1
            elif entry.is_dir():
                count += _count_files_recursive(entry)
    except PermissionError:
        logger.debug("permission_denied_counting_files", path=str(root))
    return count


def _collect_main_folders(root: Path) -> list[str]:
    """Return top-level non-hidden directories (up to 30)."""
    skip: Final[set[str]] = {
        ".git",
        "node_modules",
        "__pycache__",
        ".venv",
        "venv",
        "aicippy-ref",
    }
    folders: list[str] = []
    try:
        for entry in sorted(root.iterdir()):
            if entry.is_dir() and not entry.name.startswith(".") and entry.name not in skip:
                folders.append(entry.name + "/")
            if len(folders) >= 30:
                break
    except PermissionError:
        logger.debug("permission_denied_collecting_folders", path=str(root))
    return folders


def _detect_tech_stack(
    root: Path,
    dir_entries: list[str],
) -> list[str]:
    """Detect tech stack from filenames and directories."""
    stack: list[str] = []
    for indicator, tech in _TECH_INDICATORS.items():
        for entry in dir_entries:
            if indicator in entry:
                if tech not in stack:
                    stack.append(tech)
                break
        else:
            if (root / indicator).exists() and tech not in stack:
                stack.append(tech)
    return stack


def _detect_entry_points(
    root: Path,
    project_type: str,
) -> list[str]:
    """Detect common entry point files."""
    candidates: dict[str, list[str]] = {
        "python": [
            "src/main.py",
            "main.py",
            "app.py",
            "manage.py",
            "src/__main__.py",
        ],
        "node": [
            "src/index.ts",
            "src/index.js",
            "index.js",
            "index.ts",
            "src/main.ts",
            "src/app.ts",
        ],
        "rust": ["src/main.rs", "src/lib.rs"],
        "go": ["main.go", "cmd/main.go"],
        "java": ["src/main/java/"],
        "dart": ["lib/main.dart"],
    }
    entries: list[str] = []
    for candidate in candidates.get(project_type, []):
        if (root / candidate).exists():
            entries.append(candidate)
    return entries


class WorkspaceMemory:
    """Persistent workspace-scoped memory.

    Stores project study results, learned patterns, and conversation
    insights for a specific working directory.  All mutations are
    thread-safe via an internal ``threading.Lock``.
    """

    __slots__ = (
        "_data",
        "_lock",
        "_memory_dir",
        "_memory_file",
        "_settings",
        "_workspace_path",
    )

    def __init__(
        self,
        workspace_path: str | None = None,
    ) -> None:
        self._lock = threading.Lock()
        self._workspace_path = workspace_path or str(Path.cwd())
        self._settings = get_settings()

        # Get project-specific subfolder in the agent's central temp directory
        self._memory_dir = self._settings.get_project_temp_dir(self._workspace_path)
        self._memory_file = self._memory_dir / WORKSPACE_MEMORY_FILE
        self._data: dict[str, Any] = {}

        self._load()
        logger.debug(
            "workspace_memory_initialized",
            workspace_path=self._workspace_path,
            memory_dir=str(self._memory_dir),
            exists=self._memory_file.exists(),
        )

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------

    def _load(self) -> None:
        """Load workspace memory from disk."""
        if self._memory_file.exists():
            try:
                raw = self._memory_file.read_text(encoding="utf-8")
                self._data = json.loads(raw)
            except (json.JSONDecodeError, OSError) as exc:
                logger.warning(
                    "workspace_memory_load_failed",
                    error=str(exc),
                )
                self._data = self._empty_data()
        else:
            self._data = self._empty_data()

    def _save(self) -> None:
        """Persist workspace memory to disk (caller holds lock)."""
        self._data["updated_at"] = datetime.now(UTC).isoformat()
        try:
            self._memory_file.write_text(
                json.dumps(
                    self._data,
                    ensure_ascii=False,
                    indent=2,
                ),
                encoding="utf-8",
            )
        except OSError as exc:
            logger.warning(
                "workspace_memory_save_failed",
                error=str(exc),
            )

    def _empty_data(self) -> dict[str, Any]:
        now = datetime.now(UTC).isoformat()
        return {
            "workspace_path": self._workspace_path,
            "created_at": now,
            "updated_at": now,
            "project_study": {},
            "learned_patterns": {},
            "conversation_insights": [],
        }

    # ------------------------------------------------------------------
    # Scan
    # ------------------------------------------------------------------

    def needs_rescan(self) -> bool:
        """Return True if workspace has never been scanned or is stale."""
        study = self._data.get("project_study", {})
        last_scanned = study.get("last_scanned")
        if not last_scanned:
            return True
        try:
            scanned_dt = datetime.fromisoformat(last_scanned)
            cutoff = datetime.now(UTC) - timedelta(
                hours=RESCAN_INTERVAL_HOURS,
            )
            return scanned_dt < cutoff
        except (ValueError, TypeError):
            return True

    def scan_workspace(
        self,
        callback: Any | None = None,
    ) -> dict[str, Any]:
        """Scan the workspace directory structure.

        This is a *fast* scan — it reads only file/directory names,
        never file contents.  Progress updates are streamed to
        *callback(message: str)* if provided.

        Returns the ``project_study`` dict.
        """
        root = Path(self._workspace_path)
        if not root.is_dir():
            logger.warning(
                "workspace_scan_path_not_dir",
                path=self._workspace_path,
            )
            return self._data.get("project_study", {})

        def _cb(msg: str) -> None:
            if callback is not None:
                try:
                    callback(msg)
                except Exception:
                    logger.debug(
                        "workspace_scan_callback_error",
                        exc_info=True,
                    )

        _cb("Scanning workspace structure...")

        # List root entries
        try:
            dir_entries = sorted(e.name for e in root.iterdir())[:MAX_DIR_ENTRIES]
        except OSError:
            dir_entries = []

        # Detect project type
        project_type = "unknown"
        for marker, ptype in _PROJECT_MARKERS.items():
            if (root / marker).exists():
                project_type = ptype
                break

        _cb(f"Detected project type: {project_type}")

        # Project name from directory name
        project_name = root.name

        # Description from README first line or project name
        description = f"{project_name} ({project_type} project)"
        readme_path = root / "README.md"
        if readme_path.is_file():
            try:
                first_lines = readme_path.read_text(
                    encoding="utf-8",
                )[:300].split("\n")
                for line in first_lines:
                    stripped = line.strip().lstrip("#").strip()
                    if stripped:
                        description = stripped
                        break
            except OSError:
                logger.debug("readme_read_failed", exc_info=True)

        # Main folders
        main_folders = _collect_main_folders(root)
        _cb(f"Found {len(main_folders)} directories")

        # Key files
        key_files = [e for e in dir_entries if e in _KEY_FILE_NAMES][:MAX_KEY_FILES]

        # Tech stack
        tech_stack = _detect_tech_stack(root, dir_entries)
        if project_type != "unknown" and project_type not in tech_stack:
            tech_stack.insert(0, project_type)
        _cb(f"Tech stack: {', '.join(tech_stack) or 'none detected'}")

        # Entry points
        entry_points = _detect_entry_points(root, project_type)

        # File count
        _cb("Counting files...")
        file_count = _count_files_recursive(root)
        _cb(f"Total files: {file_count}")

        study: dict[str, Any] = {
            "name": project_name,
            "type": project_type,
            "description": description,
            "main_folders": main_folders,
            "key_files": key_files,
            "tech_stack": tech_stack,
            "entry_points": entry_points,
            "file_count": file_count,
            "last_scanned": datetime.now(UTC).isoformat(),
        }

        with self._lock:
            self._data["project_study"] = study
            self._save()

        _cb("Workspace study complete.")
        logger.info(
            "workspace_scan_completed",
            project_name=project_name,
            project_type=project_type,
            file_count=file_count,
        )
        return study

    def lightweight_rescan(self) -> None:
        """Lightweight rescan — only update file count and timestamp.

        Does NOT re-read file contents or re-detect project type.
        Suitable for periodic refresh every N messages.
        """
        root = Path(self._workspace_path)
        if not root.is_dir():
            return

        file_count = _count_files_recursive(root)
        now = datetime.now(UTC).isoformat()

        with self._lock:
            study = self._data.get("project_study", {})
            study["file_count"] = file_count
            study["last_scanned"] = now
            self._data["project_study"] = study
            self._save()

        logger.debug(
            "workspace_lightweight_rescan",
            file_count=file_count,
        )

    # ------------------------------------------------------------------
    # Summary for model injection
    # ------------------------------------------------------------------

    def get_summary(self) -> str:
        """Return a concise workspace context string for the model."""
        study = self._data.get("project_study", {})
        if not study:
            return ""

        parts: list[str] = []
        name = study.get("name", "unknown")
        ptype = study.get("type", "unknown")
        fcount = study.get("file_count", 0)
        parts.append(
            f"Workspace: {name} ({ptype}, {fcount} files)",
        )

        desc = study.get("description", "")
        if desc:
            parts.append(f"Description: {desc}")

        folders = study.get("main_folders", [])
        if folders:
            parts.append(f"Folders: {', '.join(folders[:15])}")

        stack = study.get("tech_stack", [])
        if stack:
            parts.append(f"Tech stack: {', '.join(stack)}")

        entries = study.get("entry_points", [])
        if entries:
            parts.append(f"Entry points: {', '.join(entries)}")

        key_files = study.get("key_files", [])
        if key_files:
            parts.append(f"Key files: {', '.join(key_files)}")

        # Recent learned patterns (up to 10)
        patterns = self._data.get("learned_patterns", {})
        if patterns:
            recent = sorted(
                patterns.items(),
                key=lambda x: x[1].get("updated_at", ""),
                reverse=True,
            )[:10]
            for k, v in recent:
                val = str(v.get("value", ""))[:80]
                parts.append(f"Pattern [{k}]: {val}")

        # Recent insights (up to 5)
        insights = self._data.get("conversation_insights", [])
        if insights:
            for ins in insights[-5:]:
                text = str(ins.get("insight", ""))[:120]
                parts.append(f"Insight: {text}")

        return "\n".join(parts)

    def get_status_line(self) -> str:
        """Return a short status line for display to the user."""
        study = self._data.get("project_study", {})
        if not study:
            return ""

        name = study.get("name", "unknown")
        ptype = study.get("type", "unknown")
        fcount = study.get("file_count", 0)
        last_scanned = study.get("last_scanned", "")

        age_str = ""
        if last_scanned:
            try:
                scanned_dt = datetime.fromisoformat(last_scanned)
                delta = datetime.now(UTC) - scanned_dt
                hours = int(delta.total_seconds() / 3600)
                if hours < 1:
                    minutes = int(delta.total_seconds() / 60)
                    age_str = f"{minutes}m ago"
                elif hours < 24:
                    age_str = f"{hours}h ago"
                else:
                    days = hours // 24
                    age_str = f"{days}d ago"
            except (ValueError, TypeError):
                logger.debug("age_calculation_failed", exc_info=True)

        type_label = ptype.capitalize() if ptype != "unknown" else ""
        parts = [f"{name}"]
        if type_label:
            parts.append(type_label)
        parts.append(f"{fcount} files")
        if age_str:
            parts.append(f"studied {age_str}")

        return f"Workspace: {' | '.join(parts)}"

    # ------------------------------------------------------------------
    # Learned patterns
    # ------------------------------------------------------------------

    def add_pattern(
        self,
        key: str,
        value: str,
        source: str = "auto",
    ) -> None:
        """Save a learned pattern (max ``MAX_LEARNED_PATTERNS``)."""
        with self._lock:
            patterns = self._data.setdefault(
                "learned_patterns",
                {},
            )
            patterns[key] = {
                "value": value,
                "source": source,
                "updated_at": datetime.now(UTC).isoformat(),
            }
            # Prune oldest beyond limit
            if len(patterns) > MAX_LEARNED_PATTERNS:
                sorted_keys = sorted(
                    patterns,
                    key=lambda k: patterns[k].get(
                        "updated_at",
                        "",
                    ),
                )
                excess = len(patterns) - MAX_LEARNED_PATTERNS
                for old_key in sorted_keys[:excess]:
                    del patterns[old_key]
            self._save()

    def get_pattern(
        self,
        key: str,
        default: str | None = None,
    ) -> str | None:
        """Retrieve a learned pattern value."""
        entry = self._data.get(
            "learned_patterns",
            {},
        ).get(key)
        if entry is None:
            return default
        return entry.get("value", default)

    # ------------------------------------------------------------------
    # Conversation insights
    # ------------------------------------------------------------------

    def add_insight(
        self,
        insight: str,
        session_id: str = "",
    ) -> None:
        """Save a conversation insight (max ``MAX_CONVERSATION_INSIGHTS``)."""
        with self._lock:
            insights = self._data.setdefault(
                "conversation_insights",
                [],
            )
            insights.append(
                {
                    "insight": insight,
                    "extracted_at": datetime.now(UTC).isoformat(),
                    "from_session": session_id,
                },
            )
            # Prune oldest beyond limit
            if len(insights) > MAX_CONVERSATION_INSIGHTS:
                excess = len(insights) - MAX_CONVERSATION_INSIGHTS
                del insights[:excess]
            self._save()

    # ------------------------------------------------------------------
    # Insight extraction from assistant responses
    # ------------------------------------------------------------------

    @staticmethod
    def extract_insights(
        response_text: str,
        _user_message: str = "",
    ) -> list[str]:
        """Extract actionable insights from an assistant response.

        Uses simple heuristic patterns — no additional LLM call.

        Returns a list of insight strings (may be empty).
        """
        if not response_text:
            return []

        insights: list[str] = []

        # 1. File paths mentioned via **File: path** pattern
        for match in _BOLD_FILE_PATTERN.finditer(response_text):
            fpath = match.group(1).strip()
            insights.append(f"File created/modified: {fpath}")

        # 2. File paths like src/..., lib/..., etc.
        seen_paths: set[str] = set()
        for match in _FILE_PATH_PATTERN.finditer(response_text):
            fpath = match.group(1)
            if fpath not in seen_paths:
                seen_paths.add(fpath)
                if len(seen_paths) <= 10:
                    insights.append(
                        f"File referenced: {fpath}",
                    )

        # 3. Architecture decisions
        response_lower = response_text.lower()
        for keyword in _ARCHITECTURE_KEYWORDS:
            idx = response_lower.find(keyword)
            if idx != -1:
                # Extract surrounding sentence (up to 150 chars)
                start = max(0, idx - 20)
                end = min(len(response_text), idx + 130)
                snippet = response_text[start:end].strip()
                # Clean up to sentence boundaries
                snippet = snippet.replace("\n", " ")
                insights.append(
                    f"Architecture: {snippet}",
                )
                break  # One architecture insight per response

        # 4. Error solution patterns
        if _ERROR_SOLUTION_PATTERN.search(response_text):
            # Find the fix line
            for line in response_text.split("\n"):
                if _ERROR_SOLUTION_PATTERN.search(line):
                    clean = line.strip()[:150]
                    if clean:
                        insights.append(f"Solution: {clean}")
                        break

        # 5. Configuration values
        for match in _CONFIG_VALUE_PATTERN.finditer(
            response_text,
        ):
            key = match.group(1)
            val = match.group(2)
            insights.append(
                f"Config: {key} = {val}",
            )
            break  # One config insight per response

        return insights[:10]  # Cap at 10 insights per response

    @property
    def workspace_path(self) -> str:
        """Return the absolute workspace path."""
        return self._workspace_path

    @property
    def project_study(self) -> dict[str, Any]:
        """Return the current project study data."""
        return dict(self._data.get("project_study", {}))
