"""File reading / writing helpers for abstract_pandas.

Design goal: `import abstract_pandas` stays light. Only the standard library,
pandas and the stdlib-only ``abstract_essentials`` are imported at module load.
Heavy / optional dependencies (geopandas, pdf tooling, ODS libraries, werkzeug)
are imported lazily inside the functions that actually need them, so callers who
only read CSV/Excel/JSON never pay for — or need to install — the rest.

The reader (``get_df`` and friends) is modeled on the robust implementation in
``abstract_utilities`` but carries no dependency on it.
"""
import os, shutil, tempfile, inspect, logging
from datetime import datetime
import pandas as pd
from abstract_essentials import read_from_file, write_to_file, safe_read_from_json
from .proj_tools import save_valid_crs

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


# ─── lazy heavy-dependency helpers (keep `import abstract_pandas` cheap) ──────
def _geopandas():
    """Import geopandas on demand; raises ImportError only when spatial IO is used."""
    import geopandas as gpd
    return gpd

def _file_storage_cls():
    """Return werkzeug's FileStorage, or an empty tuple so isinstance() is always False."""
    try:
        from werkzeug.datastructures import FileStorage
        return FileStorage
    except Exception:
        return ()

def _secure_filename(name):
    try:
        from werkzeug.utils import secure_filename
        return secure_filename(name)
    except Exception:
        return os.path.basename(name or "")

def convert_date_string(value):
    """Best-effort ISO date parse; returns the original value on failure."""
    try:
        return datetime.fromisoformat(str(value))
    except (ValueError, TypeError):
        return value


# ─── extension → engine maps ─────────────────────────────────────────────────
def source_ext(typ=None):
    """Engine map used by the *writer* (``save_df``)."""
    source_js = {'.parquet':'pyarrow','.txt':'python','.xlsx':'openpyxl','.xls':'openpyxl','.xlsb':'pyxlsb','.ods':'odf','.geojson':'GeoJSON'}
    if typ:
        source_js = source_js.get(typ)
    return source_js

def source_engine_for_ext(ext):
    """Engine map used by the *reader* (``get_df``)."""
    ext = (ext or '').lower()
    return {
        '.parquet': 'pyarrow', '.txt': 'python', '.csv': 'python', '.tsv': 'python',
        '.xlsx': 'openpyxl', '.xls': 'xlrd', '.xlsb': 'pyxlsb', '.ods': 'odf', '.geojson': 'GeoJSON',
    }.get(ext)


# ─── small predicates ────────────────────────────────────────────────────────
def is_file(file_path):
    if file_path not in ['', ' ', None, 'None'] and isinstance(file_path, str) and os.path.isfile(file_path):
        return os.path.splitext(file_path)[-1]

def is_valid_file_path(path):
    """Return the lowercase extension for an existing file, else None."""
    if not (isinstance(path, str) and path.strip()):
        return None
    if os.path.isfile(path):
        return os.path.splitext(path)[1].lower()
    return None

def isDataFrame(obj):
    # GeoDataFrame subclasses DataFrame, so this check covers both without importing geopandas.
    return isinstance(obj, pd.DataFrame)

is_dataframe = isDataFrame  # robust-API alias


# ─── constructors / format-specific readers ─────────────────────────────────
def create_dataframe(new_data=None, columns=None):
    if isDataFrame(new_data):
        return new_data.copy()
    new_data = new_data or {}
    if isinstance(new_data, dict):
        new_data = [new_data]
        if columns is None:
            all_keys = set()
            for row in new_data:
                if isinstance(row, dict):
                    all_keys.update(row.keys())
            columns = list(all_keys)
        if columns is False:
            columns = None
    try:
        return pd.DataFrame(new_data, columns=columns)
    except Exception as e:
        logging.error(f"Failed to create DataFrame: {e}")
        return pd.DataFrame([], columns=columns if columns not in (False,) else None)

def read_ods_file(file_path):
    """Read every sheet of an ODS file into a {sheet_name: DataFrame} dict."""
    if not is_valid_file_path(file_path):
        logging.error(f"File not found or invalid: {file_path}")
        return {}
    import ezodf
    try:
        doc = ezodf.opendoc(file_path)
    except Exception as e:
        logging.error(f"Failed to open ODS document: {e}")
        return {}
    sheets = {}
    for sheet in doc.sheets:
        rows = []
        for row in sheet.rows():
            row_data = []
            for cell in row:
                if cell.value_type == 'date':
                    row_data.append(convert_date_string(str(cell.value)))
                else:
                    row_data.append(cell.value)
            rows.append(row_data)
        sheets[sheet.name] = pd.DataFrame(rows)
    return sheets

def read_ods_as_excel(file_path, xlsx_path=None):
    """Convert an ODS file to XLSX (in a temp dir if needed) and read the first sheet."""
    if not is_valid_file_path(file_path):
        logging.error(f"File not found or invalid: {file_path}")
        return pd.DataFrame()
    cleanup_temp = xlsx_path is None
    if cleanup_temp:
        tmp_dir = tempfile.mkdtemp()
        xlsx_path = os.path.join(tmp_dir, os.path.basename(file_path) + '.xlsx')
    try:
        ods_to_xlsx(file_path, xlsx_path)
        return pd.read_excel(xlsx_path, engine='openpyxl')
    except Exception as e:
        logging.error(f"ODS→XLSX read failed: {e}")
        return pd.DataFrame()
    finally:
        if cleanup_temp:
            shutil.rmtree(tmp_dir, ignore_errors=True)

# backwards-compatible alias
def read_ods(file_path, xlsx_path=None):
    return read_ods_as_excel(file_path, xlsx_path)

def filter_df(df, nrows=None, condition=None, indices=None):
    """Apply optional head(nrows) / boolean condition / iloc[indices] filtering."""
    if nrows is not None:
        df = df.head(nrows)
    if condition is not None:
        df = df[condition]
    if indices is not None:
        df = df.iloc[indices]
    return df

def read_shape_file(source):
    """Read a spatial file (.shp/.geojson/.prj/…). Imports geopandas lazily."""
    ext = is_valid_file_path(source)
    if not ext:
        logging.error(f"Shape file not found: {source}")
        return None
    try:
        if ext in ('.shp', '.cpg', '.dbf', '.shx'):
            return _geopandas().read_file(source)
        if ext == '.geojson':
            return _geopandas().read_file(source, driver='GeoJSON')
        if ext == '.prj':
            return read_from_file(source)
    except Exception as e:
        logging.error(f"Failed to read spatial data ({source}): {e}")
        return None
    logging.error(f"Unsupported spatial extension: {ext}")
    return None

def pdf_to_text(path, keep_page_breaks=True, ocr_if_empty=True):
    """Return the full text of a PDF. Imports pdfplumber / OCR tooling lazily."""
    if not os.path.exists(path):
        raise FileNotFoundError(path)
    import pdfplumber
    all_pages = []
    with pdfplumber.open(path) as pdf:
        for i, page in enumerate(pdf.pages, start=1):
            text = page.extract_text() or ""
            if (not text.strip()) and ocr_if_empty:
                from pdf2image import convert_from_path
                import pytesseract
                img = convert_from_path(str(path), dpi=300, first_page=i, last_page=i)[0]
                text = pytesseract.image_to_string(img, lang="eng")
            all_pages.append(text)
    return ("\f" if keep_page_breaks else "\n").join(all_pages)

def read_directory(root_path):
    """Walk a directory and read each file via get_df into a {relative_path: content} dict."""
    out = {}
    for dirpath, _dirs, filenames in os.walk(root_path):
        for name in filenames:
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, root_path)
            try:
                out[rel] = get_df(full)
            except Exception as e:
                logging.error(f"read_directory: failed on {full}: {e}")
                out[rel] = None
    return out


# ─── the main reader ─────────────────────────────────────────────────────────
def get_df(source=None, nrows=None, skiprows=None, condition=None, indices=None):
    """
    Load a DataFrame / GeoDataFrame from many sources, then apply optional filters.

    Accepts: an existing DataFrame/GeoDataFrame, a file path (csv/tsv/txt, xlsx/xls/
    xlsb/ods, json, parquet, shp/geojson/prj/…, pdf, or any text file), a directory
    (→ dict of contents), a werkzeug FileStorage upload, or an in-memory dict/list.
    Returns None on unreadable input rather than raising.
    """
    # Directory → dict of contents
    if isinstance(source, str) and os.path.isdir(source):
        return read_directory(source)

    # Already a DataFrame/GeoDataFrame → just filter
    if isDataFrame(source):
        return filter_df(source, nrows=nrows, condition=condition, indices=indices)

    if source is None:
        logging.error("No source provided to get_df().")
        return None

    FileStorage = _file_storage_cls()

    # File path → read by extension
    if isinstance(source, str) and os.path.isfile(source):
        ext = os.path.splitext(source)[-1].lower()
        try:
            logging.info(f"Loading file {source} with extension '{ext}'.")
            if ext in ('.csv', '.tsv', '.txt'):
                sep = {'.csv': ',', '.tsv': '\t', '.txt': None}.get(ext)
                df = pd.read_csv(source, skiprows=skiprows, sep=sep, nrows=nrows)
            elif ext in ('.ods', '.xlsx', '.xls', '.xlsb'):
                if ext == '.ods':
                    df = read_ods_as_excel(source)
                else:
                    df = pd.read_excel(source, skiprows=skiprows, engine=source_engine_for_ext(ext), nrows=nrows)
            elif ext == '.json':
                data = safe_read_from_json(source)
                try:
                    df = pd.DataFrame(data if isinstance(data, list) else [data])
                except Exception:
                    return data
            elif ext == '.parquet':
                df = pd.read_parquet(source)
            elif ext in ('.shp', '.cpg', '.dbf', '.shx', '.geojson', '.prj'):
                return read_shape_file(source)
            elif ext == '.pdf':
                return pdf_to_text(source)
            else:
                return read_from_file(source)

            if not isinstance(df, (dict, list, FileStorage)):
                return filter_df(df, nrows=nrows, condition=condition, indices=indices)
            source = df  # fall through to the in-memory handling below
        except Exception as e:
            logging.error(f"Failed to read '{source}': {e}")
            return None

    # Uploaded file (werkzeug FileStorage)
    if FileStorage and isinstance(source, FileStorage):
        try:
            logging.info(f"Reading uploaded file: {_secure_filename(source.filename)}")
            df = pd.read_excel(source.stream, nrows=nrows)
            return filter_df(df, nrows=nrows, condition=condition, indices=indices)
        except Exception as e:
            logging.error(f"Failed to read FileStorage: {e}")
            return None

    # In-memory dict / list
    if isinstance(source, (dict, list)):
        logging.info("Creating DataFrame from in-memory data structure.")
        df = pd.DataFrame(source)
        return filter_df(df, nrows=nrows, condition=condition, indices=indices)

    logging.error(f"Unsupported source type: {type(source)}")
    return None


def _is_geodataframe(obj):
    """True if obj is a geopandas GeoDataFrame — checked via the MRO so plain
    DataFrames don't trigger a geopandas import."""
    return any(
        c.__name__ == 'GeoDataFrame' and c.__module__.split('.')[0] == 'geopandas'
        for c in type(obj).__mro__
    )

def _df_to_csv_text(obj):
    """Render a DataFrame/GeoDataFrame as CSV text (geometry → WKT)."""
    if _is_geodataframe(obj) and 'geometry' in getattr(obj, 'columns', []):
        gdf = obj.copy()
        gdf["geometry"] = gdf["geometry"].apply(lambda g: g.wkt if g is not None else "")
        return gdf.to_csv(index=False)
    return obj.to_csv(index=False)

def read_any_file(full_path):
    """Read one path (file or directory) and return its content as text (CSV for tables)."""
    if not os.path.exists(full_path):
        raise FileNotFoundError(f"Not a valid path: {full_path!r}")

    if os.path.isdir(full_path):
        data = None
        for _rel, content in read_directory(full_path).items():
            if isDataFrame(content):
                data = _df_to_csv_text(content)
            else:
                data = content
        return data

    result = get_df(full_path)
    if isinstance(result, dict):
        data = None
        for key, value in result.items():
            if isDataFrame(value):
                data = f"=== {key} ===\n" + _df_to_csv_text(value)
            else:
                data = f"=== {key} ===\n" + str(value)
        return data
    if isDataFrame(result):
        return _df_to_csv_text(result)
    if isinstance(result, list):
        try:
            return pd.DataFrame(result).to_csv(index=False)
        except Exception:
            return repr(result)
    if isinstance(result, str):
        return result
    # Last resort: read the raw text
    try:
        with open(full_path, "r", encoding="utf-8", errors="replace") as f:
            return f.read()
    except Exception as e:
        raise ValueError(f"Error reading file {full_path!r}: {e}")

def read_file_as_text(paths):
    """Return a list of textual representations for one path or a list of paths."""
    files_to_process = [paths] if isinstance(paths, str) else list(paths)
    return [read_any_file(full_path) for full_path in files_to_process]


# ─── writers / path management (unchanged behavior) ─────────────────────────
def save_df(df, file_path, index=None, suffix=None, engine=None):
    df = get_df(df)
    suffix = suffix or os.path.splitext(file_path)[-1] or '.xlsx'
    logging.info(f"saving df with suffix {suffix} to {file_path}")
    try:
        if suffix in ['.ods', '.xlsx', '.xls', '.xlsb']:
            df.to_excel(file_path, engine=engine or source_ext(suffix))
        elif suffix in ['.csv', '.tsv', '.txt']:
            sep = {'.csv': ',', '.tsv': '\t', '.txt': '\t'}.get(suffix, ',')
            df.to_csv(file_path, sep=sep)
        elif suffix in [".shp", ".cpg", ".dbf", ".shx", '.geojson']:
            df.to_file(file_path, driver=source_ext(suffix))
        elif suffix in ['.prj']:
            save_valid_crs(df, file_path=file_path)
        elif suffix == '.parquet':
            df.to_parquet(file_path, engine=engine or source_ext(suffix))
        else:
            logging.info("could not find appropriate file type, attempting generic file save")
            try:
                write_to_file(contents=str(df), file_path=file_path)
                logging.info(f"file save for file path {file_path} succesful")
                return True
            except Exception as e:
                logging.info(f"file save for file path {file_path} unsuccesful:\n {e}")
                return False
        logging.info(f"file save for file path {file_path} succesful")
        return True
    except Exception as e:
        logging.info(f"Failed to save file: {e}")
        return False

def safe_excel_save(df, original_file_path, index=False, suffix=None, engine=None):
    suffix = suffix or os.path.splitext(original_file_path)[-1] or '.xlsx'
    result = None
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        temp_file_name = tmp.name
        result = save_df(df, temp_file_name, index=index, suffix=suffix, engine=engine)
        logging.info(f"{temp_file_name} {os.path.isfile(temp_file_name)},{os.path.getsize(temp_file_name)}")
    if result and os.path.getsize(temp_file_name) > 0:
        shutil.move(temp_file_name, original_file_path)
    else:
        logging.info("Temporary file is empty or wasn't written correctly. Original file is unchanged.")
    if os.path.exists(temp_file_name):
        os.remove(temp_file_name)

def move_excel_file(current_path, target_path):
    """Move a file from current_path to target_path. Returns True on success."""
    try:
        if not os.path.isfile(current_path):
            print(f"The file {current_path} does not exist.")
            return False
        shutil.move(current_path, target_path)
        print(f"File moved successfully from {current_path} to {target_path}")
        return True
    except Exception as e:
        print(f"Error moving the file: {e}")
        return False

def unique_name(base_path, suffix='_', ext='.xlsx'):
    """Generate a unique file path using a datetime stamp, incrementing on collision."""
    datetime_suffix = datetime.now().strftime("%Y%m%d%H%M%S")
    unique_path = f"{base_path}{suffix}{datetime_suffix}{ext}"
    counter = 1
    while os.path.isfile(unique_path):
        unique_path = f"{base_path}{suffix}{datetime_suffix}_{counter}{ext}"
        counter += 1
    return unique_path

def get_new_excel_path(source=None):
    """Derive a new non-conflicting Excel file path from a DataFrame, path, or bytes."""
    default_filename = "new_excel.xlsx"
    if isinstance(source, pd.DataFrame):
        return unique_name(os.path.splitext(default_filename)[0])
    elif isinstance(source, (str, bytes)):
        if isinstance(source, bytes):
            try:
                source = source.decode('utf-8')
            except UnicodeDecodeError:
                print("Error: Bytes source could not be decoded to a string.")
                return unique_name(os.path.splitext(default_filename)[0])
        if os.path.isfile(source):
            base_path, _ = os.path.splitext(source)
            return unique_name(base_path)
        else:
            return source
    else:
        return unique_name(os.path.splitext(default_filename)[0])

def ods_to_xlsx(ods_path, xlsx_path):
    """Convert the first sheet of an ODS file to XLSX. Imports odf lazily."""
    from odf.table import Table, TableRow, TableCell
    from odf.opendocument import load
    from odf import teletype
    doc = load(ods_path)
    data_frames = []
    for table in doc.spreadsheet.getElementsByType(Table):
        rows = []
        for row in table.getElementsByType(TableRow):
            cells = []
            for cell in row.getElementsByType(TableCell):
                repeat = cell.getAttribute("numbercolumnsrepeated") or 1
                cell_data = teletype.extractText(cell) or ""
                cells.extend([cell_data] * int(repeat))
            if cells:
                rows.append(cells)
        data_frames.append(pd.DataFrame(rows))
    if data_frames:
        data_frames[0].to_excel(xlsx_path, index=False)

def get_caller_path():
    """Return the absolute directory of the script that called this function."""
    stack = inspect.stack()
    caller_frame = stack[1]
    caller_path = os.path.abspath(caller_frame.filename)
    return os.path.dirname(caller_path)

def mkdir(base_path, subfolder):
    """Ensure a subdirectory exists in base_path and return its path."""
    full_path = os.path.join(base_path, subfolder)
    if not os.path.exists(full_path):
        os.makedirs(full_path, exist_ok=True)
    return full_path

def get_excels_dir(abs_dir=None):
    """Get or create an 'excels' directory in the given or caller's directory."""
    abs_dir = abs_dir or get_caller_path()
    return mkdir(abs_dir, 'excels')

def get_filtered_dir():
    """Get or create a 'filtered' directory inside 'excels'."""
    return mkdir(get_excels_dir(), 'filtered')

def get_original_dir():
    """Get or create an 'original' directory inside 'excels'."""
    return mkdir(get_excels_dir(), 'original')

def get_path_pieces(file_path):
    baseName = os.path.basename(file_path)
    fileName, ext = os.path.splitext(baseName)
    return baseName, fileName, ext

def get_filtered_file_path(file_path, filter_type='filtered'):
    baseName, fileName, ext = get_path_pieces(file_path)
    return os.path.join(get_filtered_dir(), f"{fileName}_{filter_type}{ext}")

def get_original_file_path(file_path):
    baseName, fileName, ext = get_path_pieces(file_path)
    return os.path.join(get_original_dir(), baseName)

def save_original_excel(file_path, original_dir=None):
    df = get_df(file_path)
    original_dir = original_dir or os.getcwd()
    original_file_path = get_original_file_path(file_path)
    safe_excel_save(df, original_file_path)
    return original_file_path

def save_filtered_excel(df, file_path, filter_type='filtered'):
    df = get_df(df)
    filtered_file_path = get_filtered_file_path(file_path, filter_type=filter_type)
    safe_excel_save(df, filtered_file_path)
    return filtered_file_path
