# ---------------------------------------------------------------------------
#  Copyright (c) DevSecCode. All rights reserved.
# ---------------------------------------------------------------------------
"""Find-references and rename operations over the resolved symbol graph."""

from __future__ import annotations

import json
import logging
import os
from typing import Optional

from dsc_api.services.code_intel.dtos import (
    EdgeKind,
    FileEdit,
    ReferenceLocation,
    SymbolRecord,
)
from dsc_api.services.code_intel.store import CodeIntelStore

logger = logging.getLogger(__name__)


class RefactorEngine:
    """Provides find-references and rename refactoring over the code intel graph."""

    def __init__(self, store: CodeIntelStore, workspace_root: str) -> None:
        self._store = store
        self._workspace_root = workspace_root
        # LRU-style cache for source lines read during a single operation.
        # Keyed by absolute file path, value is the full list of lines.
        self._line_cache: dict[str, list[str]] = {}

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def find_references(self, symbol_id: int) -> list[ReferenceLocation]:
        """Find all references to a symbol across the workspace.

        Collects definition, import, call, heritage, and override sites.
        """
        symbol = self._get_symbol(symbol_id)
        if symbol is None:
            logger.warning("find_references: symbol %d not found", symbol_id)
            return []

        self._line_cache.clear()
        refs: list[ReferenceLocation] = []

        # 1. Definition site
        defn = self._definition_ref(symbol)
        if defn is not None:
            refs.append(defn)

        # 2. Import references
        refs.extend(self._import_refs(symbol))

        # 3. Call references
        refs.extend(self._call_refs(symbol))

        # 4. Heritage references (extends / implements)
        refs.extend(self._heritage_refs(symbol))

        # 5. Override references
        refs.extend(self._override_refs(symbol))

        self._line_cache.clear()
        return refs

    def compute_rename(self, symbol_id: int, new_name: str) -> list[FileEdit]:
        """Compute the set of text edits required to rename *symbol_id* to *new_name*.

        Returns one ``FileEdit`` per reference site.  Callers are responsible
        for applying the edits (in reverse line order per file) and writing
        the files back to disk.
        """
        refs = self.find_references(symbol_id)
        if not refs:
            return []

        symbol = self._get_symbol(symbol_id)
        if symbol is None:
            return []

        old_name = symbol.name
        edits: list[FileEdit] = []

        for ref in refs:
            edit = self._ref_to_edit(ref, old_name, new_name)
            if edit is not None:
                edits.append(edit)

        return edits

    # ------------------------------------------------------------------
    # Reference collectors
    # ------------------------------------------------------------------

    def _definition_ref(self, symbol: SymbolRecord) -> Optional[ReferenceLocation]:
        """Build a ReferenceLocation for the symbol's own declaration."""
        abs_path = self._abs(symbol.file_path)
        line_text = self._read_line(abs_path, symbol.start_line)
        if not line_text:
            return None

        col, end_col = self._find_name_in_line(
            line_text, symbol.name, symbol.start_column,
        )
        return ReferenceLocation(
            file_path=symbol.file_path,
            line=symbol.start_line,
            column=col,
            end_column=end_col,
            kind="definition",
            text=symbol.name,
        )

    def _import_refs(self, symbol: SymbolRecord) -> list[ReferenceLocation]:
        """Collect all import sites that reference *symbol*."""
        edges = self._edges_by_target(symbol.id, EdgeKind.IMPORTS)
        refs: list[ReferenceLocation] = []

        for edge in edges:
            source_sym = self._get_symbol(edge["source_symbol_id"])
            if source_sym is None:
                continue

            file_id = source_sym.file_id
            raw_imports = self._raw_imports_for_file(file_id)

            for imp in raw_imports:
                # Match: the import must reference the symbol's name
                names: list[str] = json.loads(imp["imported_names"] or "[]")
                is_default = bool(imp["is_default"])

                if symbol.name in names or (
                    is_default and symbol.is_default_export
                ):
                    imp_line = imp["line"]
                    abs_path = self._abs(source_sym.file_path)
                    line_text = self._read_line(abs_path, imp_line)
                    if not line_text:
                        continue

                    col, end_col = self._find_name_in_line(
                        line_text, symbol.name, 0,
                    )
                    refs.append(ReferenceLocation(
                        file_path=source_sym.file_path,
                        line=imp_line,
                        column=col,
                        end_column=end_col,
                        kind="import",
                        text=symbol.name,
                    ))
        return refs

    def _call_refs(self, symbol: SymbolRecord) -> list[ReferenceLocation]:
        """Collect all call sites that invoke *symbol*."""
        edges = self._edges_by_target(symbol.id, EdgeKind.CALLS)
        refs: list[ReferenceLocation] = []

        for edge in edges:
            source_sym = self._get_symbol(edge["source_symbol_id"])
            if source_sym is None:
                continue

            file_id = source_sym.file_id
            raw_calls = self._raw_calls_for_file(file_id)

            for call in raw_calls:
                if call["callee_name"] != symbol.name:
                    continue
                # Match caller_symbol_id if available
                if (
                    call["caller_symbol_id"] is not None
                    and call["caller_symbol_id"] != source_sym.id
                ):
                    continue

                call_line = call["line"]
                call_col = call["col"]
                abs_path = self._abs(source_sym.file_path)
                line_text = self._read_line(abs_path, call_line)
                if not line_text:
                    continue

                col, end_col = self._find_name_in_line(
                    line_text, symbol.name, call_col,
                )

                refs.append(ReferenceLocation(
                    file_path=source_sym.file_path,
                    line=call_line,
                    column=col,
                    end_column=end_col,
                    kind="call",
                    text=symbol.name,
                ))
        return refs

    def _heritage_refs(self, symbol: SymbolRecord) -> list[ReferenceLocation]:
        """Collect extends/implements references to *symbol*."""
        heritage_kinds = (EdgeKind.EXTENDS, EdgeKind.IMPLEMENTS)
        refs: list[ReferenceLocation] = []

        for kind in heritage_kinds:
            edges = self._edges_by_target(symbol.id, kind)
            for edge in edges:
                source_sym = self._get_symbol(edge["source_symbol_id"])
                if source_sym is None:
                    continue

                file_id = source_sym.file_id
                raw_heritages = self._raw_heritage_for_file(file_id)

                for h in raw_heritages:
                    if h["parent_name"] != symbol.name:
                        continue

                    h_line = h["line"]
                    abs_path = self._abs(source_sym.file_path)
                    line_text = self._read_line(abs_path, h_line)
                    if not line_text:
                        continue

                    col, end_col = self._find_name_in_line(
                        line_text, symbol.name, 0,
                    )
                    refs.append(ReferenceLocation(
                        file_path=source_sym.file_path,
                        line=h_line,
                        column=col,
                        end_column=end_col,
                        kind="heritage",
                        text=symbol.name,
                    ))
        return refs

    def _override_refs(self, symbol: SymbolRecord) -> list[ReferenceLocation]:
        """Collect override sites where a child method overrides *symbol*."""
        edges = self._edges_by_target(symbol.id, EdgeKind.OVERRIDES)
        refs: list[ReferenceLocation] = []

        for edge in edges:
            source_sym = self._get_symbol(edge["source_symbol_id"])
            if source_sym is None:
                continue

            abs_path = self._abs(source_sym.file_path)
            line_text = self._read_line(abs_path, source_sym.start_line)
            if not line_text:
                continue

            col, end_col = self._find_name_in_line(
                line_text, symbol.name, source_sym.start_column,
            )
            refs.append(ReferenceLocation(
                file_path=source_sym.file_path,
                line=source_sym.start_line,
                column=col,
                end_column=end_col,
                kind="override",
                text=symbol.name,
            ))
        return refs

    # ------------------------------------------------------------------
    # Rename edit computation
    # ------------------------------------------------------------------

    def _ref_to_edit(
        self,
        ref: ReferenceLocation,
        old_name: str,
        new_name: str,
    ) -> Optional[FileEdit]:
        """Convert a single reference location into a FileEdit for renaming.

        Handles special cases:
        - Import with alias: do NOT rename the alias, only the original name.
        - Member access (``receiver.method()``): replace only the property part.
        """
        abs_path = self._abs(ref.file_path)
        line_text = self._read_line(abs_path, ref.line)
        if not line_text:
            return None

        # For import refs, check if this is a default import with alias.
        # Pattern: ``import Foo as Bar`` -- we rename Foo, not Bar.
        if ref.kind == "import":
            alias_marker = f"{old_name} as "
            if alias_marker not in line_text:
                # Named import: ``{ OldName }`` -> ``{ NewName }``
                # or bare default: ``import OldName`` -> ``import NewName``
                pass  # fall through to standard replacement
            # else: the old_name portion still needs to be replaced
            # (the alias stays untouched since we target old_name specifically)

        # Verify the text at the recorded span actually matches
        span_text = line_text[ref.column:ref.end_column]
        if span_text != old_name:
            # Columns may have drifted; try to relocate
            col, end_col = self._find_name_in_line(line_text, old_name, ref.column)
            if col == end_col:
                logger.warning(
                    "rename: could not locate '%s' on %s:%d",
                    old_name, ref.file_path, ref.line,
                )
                return None
            return FileEdit(
                file_path=ref.file_path,
                line=ref.line,
                column=col,
                end_column=end_col,
                old_text=old_name,
                new_text=new_name,
                kind=ref.kind,
            )

        return FileEdit(
            file_path=ref.file_path,
            line=ref.line,
            column=ref.column,
            end_column=ref.end_column,
            old_text=old_name,
            new_text=new_name,
            kind=ref.kind,
        )

    # ------------------------------------------------------------------
    # Store query helpers
    # ------------------------------------------------------------------

    def _get_symbol(self, symbol_id: int) -> Optional[SymbolRecord]:
        """Fetch a symbol by id with its file path."""
        row = self._store.db.execute(
            """SELECT s.*, f.path AS file_path_joined
               FROM symbols s
               JOIN files f ON f.id = s.file_id
               WHERE s.id = ?""",
            (symbol_id,),
        ).fetchone()
        if row is None:
            return None
        return SymbolRecord(
            id=row["id"],
            name=row["name"],
            qualified_name=row["qualified_name"],
            kind=row["kind"],
            file_id=row["file_id"],
            file_path=row["file_path_joined"],
            start_line=row["start_line"],
            end_line=row["end_line"],
            start_column=row["start_column"],
            end_column=row["end_column"],
            exported=bool(row["exported"]),
            is_default_export=bool(row["is_default_export"]),
            signature=row["signature"],
            parent_symbol_id=row["parent_symbol_id"],
        )

    def _edges_by_target(self, target_id: int, kind: EdgeKind) -> list[dict]:
        """Return resolved_edges rows where target_symbol_id matches."""
        rows = self._store.db.execute(
            "SELECT * FROM resolved_edges WHERE target_symbol_id = ? AND kind = ?",
            (target_id, kind.value),
        ).fetchall()
        return [dict(r) for r in rows]

    def _raw_imports_for_file(self, file_id: int) -> list[dict]:
        rows = self._store.db.execute(
            "SELECT * FROM raw_imports WHERE file_id = ?", (file_id,),
        ).fetchall()
        return [dict(r) for r in rows]

    def _raw_calls_for_file(self, file_id: int) -> list[dict]:
        rows = self._store.db.execute(
            "SELECT * FROM raw_calls WHERE file_id = ?", (file_id,),
        ).fetchall()
        return [dict(r) for r in rows]

    def _raw_heritage_for_file(self, file_id: int) -> list[dict]:
        rows = self._store.db.execute(
            "SELECT * FROM raw_heritage WHERE file_id = ?", (file_id,),
        ).fetchall()
        return [dict(r) for r in rows]

    # ------------------------------------------------------------------
    # File I/O helpers
    # ------------------------------------------------------------------

    def _abs(self, rel_path: str) -> str:
        """Resolve a workspace-relative path to an absolute path."""
        if os.path.isabs(rel_path):
            return rel_path
        return os.path.join(self._workspace_root, rel_path)

    def _read_line(self, file_path: str, line: int) -> str:
        """Read a single 1-indexed line from a source file.

        Returns the line content (without trailing newline) or an empty
        string if the file or line is unavailable.
        """
        if line < 1:
            return ""

        lines = self._line_cache.get(file_path)
        if lines is None:
            try:
                with open(file_path, "r", encoding="utf-8", errors="replace") as f:
                    lines = f.readlines()
                self._line_cache[file_path] = lines
            except OSError:
                logger.debug("_read_line: cannot read %s", file_path)
                return ""

        idx = line - 1
        if idx < 0 or idx >= len(lines):
            return ""
        return lines[idx].rstrip("\n\r")

    def _find_name_in_line(
        self,
        line_text: str,
        name: str,
        hint_column: int,
    ) -> tuple[int, int]:
        """Locate *name* in *line_text*, preferring the occurrence closest to *hint_column*.

        Returns ``(column, end_column)`` as 0-based indices.
        If *name* is not found, returns ``(0, 0)``.
        """
        if not name or not line_text:
            return (0, 0)

        # Gather all occurrences
        occurrences: list[int] = []
        start = 0
        while True:
            idx = line_text.find(name, start)
            if idx == -1:
                break
            # Ensure we match a whole token boundary (not a substring of a
            # larger identifier).  Characters immediately before/after must
            # not be alphanumeric or underscore.
            before_ok = idx == 0 or not (line_text[idx - 1].isalnum() or line_text[idx - 1] == "_")
            after_idx = idx + len(name)
            after_ok = after_idx >= len(line_text) or not (
                line_text[after_idx].isalnum() or line_text[after_idx] == "_"
            )
            if before_ok and after_ok:
                occurrences.append(idx)
            start = idx + 1

        if not occurrences:
            return (0, 0)

        # Pick the occurrence closest to hint_column
        best = min(occurrences, key=lambda o: abs(o - hint_column))
        return (best, best + len(name))
