from __future__ import annotations
# text_reader.py
# One reader: path in, text out. Everything is for string search.
#
# Replaces file_reader.py / file_readers.py / directory_reader.py.
# stdlib-only at import time. Heavy deps (pdfplumber, pytesseract, xlrd,
# pyxlsb, dbfread) are lazy-loaded per-extension via require() and fail
# loudly with an install hint only when that extension is actually read.
#
# Conventions: registries over globals, frozen dataclasses as schemas,
# errors as data, explicit config wiring, os.path only.

import os
import io
import re
import csv
import json
import fnmatch
import zipfile
import importlib
from dataclasses import dataclass, field
from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union

# ─── lazy optional deps ──────────────────────────────────────────────────────

_MODULE_CACHE: Dict[str, object] = {}
_INSTALL_HINTS = {
    "pdfplumber":   "pip install pdfplumber",
    "pytesseract":  "pip install pytesseract  (plus tesseract-ocr system package)",
    "pdf2image":    "pip install pdf2image  (plus poppler-utils system package)",
    "xlrd":         "pip install xlrd",
    "pyxlsb":       "pip install pyxlsb",
    "dbfread":      "pip install dbfread",
}

def require(name: str):
    mod = _MODULE_CACHE.get(name)
    if mod is None:
        try:
            mod = importlib.import_module(name)
        except ImportError as e:
            hint = _INSTALL_HINTS.get(name, f"pip install {name}")
            raise ImportError(f"{name!r} is required for this file type; {hint}") from e
        _MODULE_CACHE[name] = mod
    return mod

# ─── schemas ─────────────────────────────────────────────────────────────────

@dataclass(frozen=True)
class ExtractedText:
    path: str
    text: str = ""
    error: str = ""          # errors as data; error != "" means text is unusable

    @property
    def ok(self) -> bool:
        return self.error == ""

@dataclass(frozen=True)
class SearchHit:
    path: str
    line_no: int
    line: str

@dataclass(frozen=True)
class WalkConfig:
    exclude_dirs: Tuple[str, ...] = (
        "node_modules", "__pycache__", ".git", ".venv", "venv",
        ".mypy_cache", ".pytest_cache", "dist", "build", ".next",
    )
    exclude_file_patterns: Tuple[str, ...] = (
        "*.pyc", "*.pyo", "*.so", "*.o", "*.a", "*.ini", "*.lock",
        "*.min.js", "*.map",
    )
    # binary/media types we never even attempt
    exclude_exts: Tuple[str, ...] = (
        ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svgz",
        ".mp3", ".wav", ".flac", ".ogg", ".mp4", ".mkv", ".avi", ".mov",
        ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
        ".bin", ".exe", ".dll", ".iso", ".img",
        ".pt", ".pth", ".safetensors", ".gguf", ".onnx",
        ".woff", ".woff2", ".ttf", ".otf", ".eot",
        ".shp", ".shx", ".prj", ".cpg",   # geometry sidecars; text lives in .dbf
    )
    max_file_bytes: int = 50 * 1024 * 1024   # skip anything larger; explicit, not smart

DEFAULT_WALK = WalkConfig()

# ─── extractor registry ──────────────────────────────────────────────────────

EXTRACTOR_REGISTRY: Dict[str, Callable[[str], str]] = {}

def register_extractor(*exts: str):
    def deco(fn: Callable[[str], str]):
        for ext in exts:
            EXTRACTOR_REGISTRY[ext.lower()] = fn
        return fn
    return deco

# ─── zip+xml helpers (xlsx / docx / ods / pptx are zips of xml) ──────────────

_TAG_RE = re.compile(r"<[^>]+>")
_BOUNDARY_RE = re.compile(r"</(?:t|si|w:p|p|text:p|table:table-row|row|a:p)>")

def _xml_to_text(xml: str) -> str:
    xml = _BOUNDARY_RE.sub("\n", xml)          # cell/paragraph ends → newline
    text = _TAG_RE.sub(" ", xml)
    # collapse intra-line whitespace, keep line structure
    lines = (re.sub(r"[ \t]+", " ", ln).strip() for ln in text.splitlines())
    return "\n".join(ln for ln in lines if ln)

def _zip_xml_text(path: str, name_filter: Callable[[str], bool]) -> str:
    parts: List[str] = []
    with zipfile.ZipFile(path) as z:
        for name in sorted(z.namelist()):
            if name_filter(name):
                parts.append(_xml_to_text(z.read(name).decode("utf-8", "replace")))
    return "\n".join(p for p in parts if p)

# ─── extractors: stdlib-only tier ────────────────────────────────────────────

@register_extractor(
    ".txt", ".md", ".rst", ".log", ".csv", ".tsv",
    ".py", ".js", ".jsx", ".ts", ".tsx", ".sh", ".sql", ".c", ".h", ".cpp",
    ".html", ".htm", ".css", ".xml", ".svg", ".yml", ".yaml", ".toml",
    ".cfg", ".conf", ".env", ".service", ".json", ".geojson", ".ndjson",
    ".prj",
)
def extract_plain(path: str) -> str:
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        return f.read()

@register_extractor(".xlsx", ".xlsm")
def extract_xlsx(path: str) -> str:
    return _zip_xml_text(
        path,
        lambda n: n == "xl/sharedStrings.xml" or n.startswith("xl/worksheets/sheet"),
    )

@register_extractor(".docx")
def extract_docx(path: str) -> str:
    return _zip_xml_text(path, lambda n: n == "word/document.xml")

@register_extractor(".ods", ".odt", ".odp")
def extract_odf(path: str) -> str:
    return _zip_xml_text(path, lambda n: n == "content.xml")

@register_extractor(".pptx")
def extract_pptx(path: str) -> str:
    return _zip_xml_text(path, lambda n: n.startswith("ppt/slides/slide"))

@register_extractor(".parquet")
def extract_parquet(path: str) -> str:
    # search-oriented: pull the string/byte columns without pandas
    pq = require("pyarrow.parquet")
    table = pq.read_table(path)
    lines: List[str] = ["\t".join(table.column_names)]
    cols = [table.column(i).to_pylist() for i in range(table.num_columns)]
    for row in zip(*cols):
        lines.append("\t".join("" if v is None else str(v) for v in row))
    return "\n".join(lines)

# ─── extractors: lazy heavy tier ─────────────────────────────────────────────

@register_extractor(".pdf")
def extract_pdf(path: str) -> str:
    pdfplumber = require("pdfplumber")
    pages: List[str] = []
    with pdfplumber.open(path) as pdf:
        for i, page in enumerate(pdf.pages, start=1):
            text = page.extract_text() or ""
            if not text.strip():
                text = _ocr_pdf_page(path, i)
            pages.append(text)
    return "\f".join(pages)

def _ocr_pdf_page(path: str, page_no: int) -> str:
    try:
        pdf2image = require("pdf2image")
        pytesseract = require("pytesseract")
    except ImportError:
        return ""   # no OCR stack installed → empty page, not a crash
    imgs = pdf2image.convert_from_path(path, dpi=300, first_page=page_no, last_page=page_no)
    return pytesseract.image_to_string(imgs[0], lang="eng") if imgs else ""

@register_extractor(".xls")
def extract_xls(path: str) -> str:
    xlrd = require("xlrd")
    book = xlrd.open_workbook(path)
    lines: List[str] = []
    for sheet in book.sheets():
        lines.append(f"=== {sheet.name} ===")
        for r in range(sheet.nrows):
            lines.append("\t".join(str(c.value) for c in sheet.row(r)))
    return "\n".join(lines)

@register_extractor(".xlsb")
def extract_xlsb(path: str) -> str:
    pyxlsb = require("pyxlsb")
    lines: List[str] = []
    with pyxlsb.open_workbook(path) as wb:
        for name in wb.sheets:
            lines.append(f"=== {name} ===")
            with wb.get_sheet(name) as sheet:
                for row in sheet.rows():
                    lines.append("\t".join("" if c.v is None else str(c.v) for c in row))
    return "\n".join(lines)

@register_extractor(".dbf")
def extract_dbf(path: str) -> str:
    # shapefile attribute table — the only searchable part of a shapefile set
    dbfread = require("dbfread")
    lines: List[str] = []
    for rec in dbfread.DBF(path, char_decode_errors="replace"):
        lines.append("\t".join(str(v) for v in rec.values()))
    return "\n".join(lines)

# ─── core API ────────────────────────────────────────────────────────────────

def extract_text(path: str) -> ExtractedText:
    """Path in, ExtractedText out. Never raises for content problems."""
    if not (isinstance(path, str) and path.strip()):
        return ExtractedText(path=str(path), error="empty or non-string path")
    if not os.path.isfile(path):
        return ExtractedText(path=path, error="not a file")
    ext = os.path.splitext(path)[1].lower()
    fn = EXTRACTOR_REGISTRY.get(ext)
    if fn is None:
        # unknown extension: try utf-8 text; garbage in → replacement chars, still greppable
        fn = extract_plain
    try:
        return ExtractedText(path=path, text=fn(path))
    except ImportError as e:
        return ExtractedText(path=path, error=str(e))          # missing optional dep
    except Exception as e:
        return ExtractedText(path=path, error=f"{type(e).__name__}: {e}")

def _skip_file(name: str, path: str, cfg: WalkConfig) -> bool:
    ext = os.path.splitext(name)[1].lower()
    if ext in cfg.exclude_exts:
        return True
    if any(fnmatch.fnmatch(name, pat) for pat in cfg.exclude_file_patterns):
        return True
    try:
        if os.path.getsize(path) > cfg.max_file_bytes:
            return True
    except OSError:
        return True
    return False

def iter_files(root: str, cfg: WalkConfig = DEFAULT_WALK) -> Iterable[str]:
    """Yield file paths under root (or root itself if it's a file), filtered by cfg."""
    if os.path.isfile(root):
        yield root
        return
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in cfg.exclude_dirs]
        for name in sorted(filenames):
            full = os.path.join(dirpath, name)
            if not _skip_file(name, full, cfg):
                yield full

def read_paths(
    paths: Union[str, List[str]],
    cfg: WalkConfig = DEFAULT_WALK,
) -> Dict[str, ExtractedText]:
    """
    Files and/or directories in → {path: ExtractedText} out.
    Failures are entries with .error set, not exceptions and not silence.
    """
    if isinstance(paths, str):
        paths = [paths]
    out: Dict[str, ExtractedText] = {}
    for p in paths:
        if not os.path.exists(p):
            out[p] = ExtractedText(path=p, error="path does not exist")
            continue
        for full in iter_files(p, cfg):
            out[full] = extract_text(full)
    return out

def search_files(
    needle: str,
    paths: Union[str, List[str]],
    cfg: WalkConfig = DEFAULT_WALK,
    case_sensitive: bool = False,
    regex: bool = False,
) -> List[SearchHit]:
    """Grep across anything read_paths can read."""
    if regex:
        pat = re.compile(needle, 0 if case_sensitive else re.IGNORECASE)
        match = pat.search
    else:
        ndl = needle if case_sensitive else needle.lower()
        match = (lambda ln: ndl in ln) if case_sensitive else (lambda ln: ndl in ln.lower())
    hits: List[SearchHit] = []
    for path, et in read_paths(paths, cfg).items():
        if not et.ok:
            continue
        for i, line in enumerate(et.text.splitlines(), start=1):
            if match(line):
                hits.append(SearchHit(path=path, line_no=i, line=line.strip()))
    return hits

# ─── explicit span reader (arbitrary line ranges, NOT string-driven) ─────────

def _normalize_ranges(ranges, total: int) -> List[Tuple[int, int]]:
    """(start, end) pairs → sorted, clamped, merged 1-based inclusive ranges.

    Accepts a single (start, end) pair OR an iterable of them. Values clamp to
    [1, total]; an inverted pair or a start past EOF drops out. Overlapping or
    touching ranges merge, so read_span never emits the same line twice.
    ranges falsy → the whole file (one span)."""
    if not ranges:
        return [(1, total)] if total else []
    # a bare (start, end) pair, not yet a list of pairs
    if len(ranges) == 2 and all(isinstance(x, int) for x in ranges):
        ranges = [ranges]
    cleaned: List[Tuple[int, int]] = []
    for r in ranges:
        try:
            start, end = int(r[0]), int(r[1])
        except (TypeError, ValueError, IndexError, KeyError):
            continue
        start = max(1, start)
        end = min(total, end)
        if start <= end:
            cleaned.append((start, end))
    cleaned.sort()
    merged: List[Tuple[int, int]] = []
    for start, end in cleaned:
        if merged and start <= merged[-1][1] + 1:
            merged[-1] = (merged[-1][0], max(merged[-1][1], end))
        else:
            merged.append((start, end))
    return merged

def read_span(path: str, ranges=None) -> Dict[str, object]:
    """Read explicit 1-based, inclusive line ranges out of a single file.

    Unlike findContent this is NOT string-driven: it renders arbitrary spans,
    which is what a file viewer needs (show lines 100-140 regardless of what
    they contain). Reads through extract_text, so it works for every format the
    registry handles (code/text, office, pdf, ...), with errors as data.

    ranges: a single (start, end) pair, or an iterable of them. Ranges clamp to
    the file, drop if empty, merge if they overlap or touch, and return in file
    order. ranges=None / empty → the whole file.

    Returns a JSON-ready dict, per-line shape matching findContent's:
      {"path": str, "total": int, "error": str,
       "spans": [{"start": int, "end": int,
                  "lines": [{"line": int, "content": str}, ...]}, ...]}
    error != "" → the file could not be read; total is 0 and spans is [].
    """
    et = extract_text(path)
    if not et.ok:
        return {"path": path, "total": 0, "error": et.error, "spans": []}
    lines = et.text.splitlines()
    total = len(lines)
    spans = [
        {"start": start, "end": end,
         "lines": [{"line": n, "content": lines[n - 1]} for n in range(start, end + 1)]}
        for start, end in _normalize_ranges(ranges, total)
    ]
    return {"path": path, "total": total, "error": "", "spans": spans}

# ─── compatibility shims for the old API surface ─────────────────────────────

def read_any_file(full_path: str) -> str:
    """Old contract: one path (file or dir) → one text blob. Raises on missing path."""
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"Not a valid path: {full_path!r}")
    results = read_paths(full_path)
    blocks: List[str] = []
    for path, et in results.items():
        body = et.text if et.ok else f"[unreadable: {et.error}]"
        blocks.append(f"=== {path} ===\n{body}" if len(results) > 1 else body)
    return "\n\n".join(blocks)

def read_file_as_text(paths: Union[str, List[str]]) -> List[str]:
    """Old contract: list of paths → list of text blobs, in input order."""
    if isinstance(paths, str):
        paths = [paths]
    return [read_any_file(p) for p in paths]

def read_directory(root_path: str, cfg: WalkConfig = DEFAULT_WALK, **_ignored) -> Dict[str, str]:
    """Old contract: dir → {path: text}. Unreadables carry an [unreadable: ...] marker."""
    return {
        path: (et.text if et.ok else f"[unreadable: {et.error}]")
        for path, et in read_paths(root_path, cfg).items()
    }

def read_files(files=None, cfg: WalkConfig = DEFAULT_WALK, **_ignored) -> Dict[str, str]:
    """Old contract from directory_reader.read_files."""
    return read_directory_many(files or [], cfg)

def read_directory_many(paths: Union[str, List[str]], cfg: WalkConfig = DEFAULT_WALK) -> Dict[str, str]:
    return {
        path: (et.text if et.ok else f"[unreadable: {et.error}]")
        for path, et in read_paths(paths, cfg).items()
    }
