"""
DataFrame + GroupedData — the core PySpark-compatible DataFrame implementation.
Backed by polars.DataFrame.
"""
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Union

import polars as pl

from singlespark.column import Column, _SortedColumn, _to_expr
from singlespark.row import Row, _rows_from_polars
from singlespark.sql.types import polars_dtype_to_spark_str, polars_schema_to_spark

if TYPE_CHECKING:
    from singlespark.session import SparkSession
    from singlespark.writer import DataFrameWriter
    from singlespark.sql.types import StructType


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _resolve_col(c) -> pl.Expr:
    """Convert a Column, string, or pl.Expr to a pl.Expr."""
    if isinstance(c, Column):
        return c._expr
    if isinstance(c, str):
        return pl.col(c)
    if isinstance(c, pl.Expr):
        return c
    raise TypeError(f"Cannot resolve column from {type(c)}: {c!r}")


def _flatten(args) -> list:
    """Flatten a single list/tuple arg or multiple args into a flat list."""
    if len(args) == 1 and isinstance(args[0], (list, tuple)):
        return list(args[0])
    return list(args)


def _parse_sort_cols(cols) -> tuple[list[pl.Expr], list[bool]]:
    """
    Parse a mix of (Column, str, _SortedColumn, pl.Expr) into (exprs, descending_flags).
    Handles col.asc() / col.desc() markers.
    """
    exprs: list[pl.Expr] = []
    desc_flags: list[bool] = []
    for c in _flatten(cols):
        if isinstance(c, _SortedColumn):
            exprs.append(c._expr)
            desc_flags.append(c._descending)
        elif isinstance(c, Column):
            exprs.append(c._expr)
            desc_flags.append(False)
        elif isinstance(c, str):
            if c.startswith("-"):
                exprs.append(pl.col(c[1:]))
                desc_flags.append(True)
            else:
                exprs.append(pl.col(c))
                desc_flags.append(False)
        elif isinstance(c, pl.Expr):
            exprs.append(c)
            desc_flags.append(False)
        else:
            raise TypeError(f"Unsupported sort column type: {type(c)}")
    return exprs, desc_flags


def _list_to_polars(data: list, schema=None) -> pl.DataFrame:
    """
    Convert a list of tuples/dicts/Rows to a Polars DataFrame.
    schema may be a StructType, a list of column names, or None.
    """
    if not data:
        if schema is not None:
            return _empty_from_schema(schema)
        return pl.DataFrame()

    from singlespark.sql.types import StructType, spark_type_to_polars

    # Determine column names
    if isinstance(schema, StructType):
        col_names = schema.fieldNames()
    elif isinstance(schema, (list, tuple)) and all(isinstance(s, str) for s in schema):
        col_names = list(schema)
        schema = None  # no type info
    else:
        col_names = None

    # Normalise rows
    first = data[0]
    if isinstance(first, dict):
        df = pl.DataFrame(data)
    elif isinstance(first, (list, tuple, Row)):
        rows = [list(r) for r in data]
        if col_names:
            df = pl.DataFrame(rows, schema=col_names, orient="row")
        else:
            df = pl.DataFrame(rows, orient="row")
    elif hasattr(first, "__iter__"):
        rows = [list(r) for r in data]
        df = pl.DataFrame(rows, orient="row")
    else:
        # Scalar list
        df = pl.DataFrame({"value": data})

    # Rename columns if needed
    if col_names and len(col_names) == df.width:
        df = df.rename(dict(zip(df.columns, col_names)))

    # Cast to schema types if StructType was provided
    if isinstance(schema, StructType):
        casts = []
        for field in schema.fields:
            if field.name in df.columns:
                casts.append(pl.col(field.name).cast(spark_type_to_polars(field.dataType)))
        if casts:
            df = df.with_columns(casts)

    return df


def _empty_from_schema(schema) -> pl.DataFrame:
    from singlespark.sql.types import StructType, spark_type_to_polars
    if isinstance(schema, StructType):
        pl_schema = {f.name: spark_type_to_polars(f.dataType) for f in schema.fields}
        return pl.DataFrame(schema=pl_schema)
    if isinstance(schema, (list, tuple)):
        return pl.DataFrame(schema=list(schema))
    return pl.DataFrame()


# ---------------------------------------------------------------------------
# GroupedData
# ---------------------------------------------------------------------------

class GroupedData:
    def __init__(self, polars_df: pl.DataFrame, group_exprs: list[pl.Expr], session: "SparkSession"):
        self._df = polars_df
        self._group_exprs = group_exprs
        self._session = session

    def agg(self, *exprs) -> "DataFrame":
        agg_exprs = [_resolve_col(e) for e in _flatten(exprs)]
        if self._group_exprs:
            result = self._df.group_by(self._group_exprs).agg(agg_exprs)
        else:
            result = self._df.select(agg_exprs)
        return DataFrame(result, self._session)

    def count(self) -> "DataFrame":
        if self._group_exprs:
            result = self._df.group_by(self._group_exprs).len()
            # Rename 'len' to 'count' for PySpark compatibility
            if "len" in result.columns:
                result = result.rename({"len": "count"})
        else:
            result = pl.DataFrame({"count": [len(self._df)]})
        return DataFrame(result, self._session)

    def sum(self, *cols) -> "DataFrame":
        targets = cols or self._df.select(pl.selectors.numeric()).columns
        agg = [pl.col(c).sum() for c in targets]
        return self.agg(*[Column(e) for e in agg])

    def avg(self, *cols) -> "DataFrame":
        targets = cols or self._df.select(pl.selectors.numeric()).columns
        agg = [pl.col(c).mean() for c in targets]
        return self.agg(*[Column(e) for e in agg])

    mean = avg

    def min(self, *cols) -> "DataFrame":
        targets = cols or self._df.columns
        return self.agg(*[Column(pl.col(c).min()) for c in targets])

    def max(self, *cols) -> "DataFrame":
        targets = cols or self._df.columns
        return self.agg(*[Column(pl.col(c).max()) for c in targets])

    def pivot(self, pivot_col: str, values=None) -> "DataFrame":
        raise NotImplementedError("pivot is not yet supported in singlespark")


# ---------------------------------------------------------------------------
# DataFrame
# ---------------------------------------------------------------------------

class DataFrame:
    """
    PySpark-compatible DataFrame backed by polars.DataFrame.
    """

    def __init__(self, _df: pl.DataFrame, session: "SparkSession"):
        self._df = _df
        self._session = session

    # ------------------------------------------------------------------
    # Schema
    # ------------------------------------------------------------------

    @property
    def columns(self) -> list[str]:
        return self._df.columns

    @property
    def dtypes(self) -> list[tuple[str, str]]:
        return [(name, polars_dtype_to_spark_str(dtype))
                for name, dtype in zip(self._df.columns, self._df.dtypes)]

    @property
    def schema(self) -> "StructType":
        return polars_schema_to_spark(dict(zip(self._df.columns, self._df.dtypes)))

    def printSchema(self) -> None:
        def _print(struct, indent=0):
            prefix = " |" + "    |" * max(0, indent - 1) + "-- " if indent else " root"
            if indent == 0:
                print("root")
                for f in struct.fields:
                    print(f" |-- {f.name}: {f.dataType.simpleString()} (nullable = {str(f.nullable).lower()})")
            else:
                for f in struct.fields:
                    print(f"{prefix}{f.name}: {f.dataType.simpleString()} (nullable = {str(f.nullable).lower()})")
        _print(self.schema)

    # ------------------------------------------------------------------
    # Selection / projection
    # ------------------------------------------------------------------

    def select(self, *cols) -> "DataFrame":
        flat = _flatten(cols)
        exprs = [_resolve_col(c) for c in flat]
        return DataFrame(self._df.select(exprs), self._session)

    def selectExpr(self, *exprs: str) -> "DataFrame":
        """Evaluate SQL expressions using DuckDB (handles any valid SQL expr)."""
        tmp = f"_selectexpr_{id(self)}"
        self._session.catalog.register(tmp, self._df)
        try:
            query = f"SELECT {', '.join(exprs)} FROM {tmp}"
            result = self._session.catalog.sql(query)
        finally:
            self._session.catalog.unregister(tmp)
        return DataFrame(result, self._session)

    def filter(self, condition) -> "DataFrame":
        if isinstance(condition, str):
            return self.selectExpr(f"* FROM __self__ WHERE {condition}") \
                if False else self._filter_sql(condition)
        expr = _resolve_col(condition)
        return DataFrame(self._df.filter(expr), self._session)

    def _filter_sql(self, sql_condition: str) -> "DataFrame":
        tmp = f"_filter_{id(self)}"
        self._session.catalog.register(tmp, self._df)
        try:
            result = self._session.catalog.sql(f"SELECT * FROM {tmp} WHERE {sql_condition}")
        finally:
            self._session.catalog.unregister(tmp)
        return DataFrame(result, self._session)

    def where(self, condition) -> "DataFrame":
        return self.filter(condition)

    def withColumn(self, name: str, col) -> "DataFrame":
        expr = _resolve_col(col).alias(name)
        return DataFrame(self._df.with_columns(expr), self._session)

    def withColumnRenamed(self, existing: str, new: str) -> "DataFrame":
        return DataFrame(self._df.rename({existing: new}), self._session)

    def withColumnsRenamed(self, rename_map: dict[str, str]) -> "DataFrame":
        return DataFrame(self._df.rename(rename_map), self._session)

    def drop(self, *cols) -> "DataFrame":
        flat = _flatten(cols)
        names = []
        for c in flat:
            if isinstance(c, str):
                names.append(c)
            elif isinstance(c, Column):
                try:
                    names.append(c._expr.meta.output_name())
                except Exception:
                    pass
        return DataFrame(self._df.drop(names), self._session)

    def toDF(self, *cols) -> "DataFrame":
        names = _flatten(cols)
        if len(names) != self._df.width:
            raise ValueError(f"Number of column names ({len(names)}) must match width ({self._df.width})")
        return DataFrame(self._df.rename(dict(zip(self._df.columns, names))), self._session)

    # ------------------------------------------------------------------
    # Aggregation
    # ------------------------------------------------------------------

    def groupBy(self, *cols) -> GroupedData:
        flat = _flatten(cols)
        exprs = [_resolve_col(c) for c in flat] if flat else []
        return GroupedData(self._df, exprs, self._session)

    groupby = groupBy  # alias

    def agg(self, *exprs) -> "DataFrame":
        return self.groupBy().agg(*exprs)

    def rollup(self, *cols) -> GroupedData:
        # Approximate with plain groupBy (rollup not supported in Polars)
        return self.groupBy(*cols)

    def cube(self, *cols) -> GroupedData:
        return self.groupBy(*cols)

    # ------------------------------------------------------------------
    # Joins
    # ------------------------------------------------------------------

    def join(self, other: "DataFrame", on=None, how: str = "inner") -> "DataFrame":
        how_map = {
            "inner": "inner",
            "left": "left",
            "left_outer": "left",
            "right": "right",
            "right_outer": "right",
            "outer": "full",
            "full": "full",
            "full_outer": "full",
            "cross": "cross",
            "semi": "semi",
            "left_semi": "semi",
            "anti": "anti",
            "left_anti": "anti",
        }
        pl_how = how_map.get(how.lower(), how)

        if on is None or pl_how == "cross":
            result = self._df.join(other._df, how="cross")
            return DataFrame(result, self._session)

        # Normalise 'on'
        if isinstance(on, str):
            on = [on]
        elif isinstance(on, Column):
            # Try to extract left_col/right_col from a simple equality expression first
            left_col, right_col = _extract_equality_join_cols(
                on, self._df.columns, other._df.columns
            )
            if left_col and right_col:
                result = self._df.join(other._df, left_on=left_col, right_on=right_col, how=pl_how)
                return DataFrame(result, self._session)
            # Fall back to DuckDB SQL join
            return self._join_via_sql(other, on, pl_how)
        elif isinstance(on, list):
            if on and isinstance(on[0], Column):
                on = [c._expr.meta.output_name() for c in on]

        result = self._df.join(other._df, on=on, how=pl_how)
        return DataFrame(result, self._session)

    def _join_via_sql(self, other: "DataFrame", condition: Column, how: str) -> "DataFrame":
        """Fall back to DuckDB for complex expression-based joins."""
        left_tmp = f"_join_left_{id(self)}"
        right_tmp = f"_join_right_{id(other)}"
        sql_how = {"semi": "INNER", "anti": "LEFT ANTI"}.get(how, how.upper())
        self._session.catalog.register(left_tmp, self._df)
        self._session.catalog.register(right_tmp, other._df)
        try:
            cond_sql = _expr_to_sql_str(condition._expr, left_tmp, right_tmp)
            query = f"SELECT * FROM {left_tmp} {sql_how} JOIN {right_tmp} ON {cond_sql}"
            result = self._session.catalog.sql(query)
        finally:
            self._session.catalog.unregister(left_tmp)
            self._session.catalog.unregister(right_tmp)
        return DataFrame(result, self._session)

    def crossJoin(self, other: "DataFrame") -> "DataFrame":
        return self.join(other, how="cross")

    # ------------------------------------------------------------------
    # Sorting
    # ------------------------------------------------------------------

    def orderBy(self, *cols, ascending=True) -> "DataFrame":
        flat = _flatten(cols)
        exprs, desc_flags = _parse_sort_cols(flat)
        # Only override direction from 'ascending' kwarg if no _SortedColumn markers were used
        has_sort_markers = any(isinstance(c, _SortedColumn) for c in flat)
        if not has_sort_markers:
            if isinstance(ascending, bool):
                desc_flags = [not ascending] * len(exprs)
            elif isinstance(ascending, (list, tuple)):
                desc_flags = [not a for a in ascending]
        return DataFrame(self._df.sort(exprs, descending=desc_flags), self._session)

    def sort(self, *cols, ascending=True) -> "DataFrame":
        return self.orderBy(*cols, ascending=ascending)

    # ------------------------------------------------------------------
    # Set operations
    # ------------------------------------------------------------------

    def union(self, other: "DataFrame") -> "DataFrame":
        return DataFrame(pl.concat([self._df, other._df], how="diagonal"), self._session)

    def unionAll(self, other: "DataFrame") -> "DataFrame":
        return self.union(other)

    def unionByName(self, other: "DataFrame", allowMissingColumns: bool = False) -> "DataFrame":
        return DataFrame(pl.concat([self._df, other._df], how="diagonal_relaxed"
                                   if allowMissingColumns else "diagonal"), self._session)

    def intersect(self, other: "DataFrame") -> "DataFrame":
        result = self._df.join(other._df, on=self._df.columns, how="semi")
        return DataFrame(result, self._session)

    def intersectAll(self, other: "DataFrame") -> "DataFrame":
        return self.intersect(other)

    def subtract(self, other: "DataFrame") -> "DataFrame":
        result = self._df.join(other._df, on=self._df.columns, how="anti")
        return DataFrame(result, self._session)

    def exceptAll(self, other: "DataFrame") -> "DataFrame":
        return self.subtract(other)

    def distinct(self) -> "DataFrame":
        return DataFrame(self._df.unique(), self._session)

    # ------------------------------------------------------------------
    # Limiting / sampling
    # ------------------------------------------------------------------

    def limit(self, n: int) -> "DataFrame":
        return DataFrame(self._df.head(n), self._session)

    def tail(self, n: int = 5) -> list[Row]:
        return _rows_from_polars(self._df.tail(n))

    def sample(self, fraction: float = None, withReplacement: bool = False,
               seed: int | None = None, n: int | None = None) -> "DataFrame":
        if n is not None:
            result = self._df.sample(n=n, with_replacement=withReplacement, seed=seed)
        else:
            result = self._df.sample(fraction=fraction or 0.1, with_replacement=withReplacement, seed=seed)
        return DataFrame(result, self._session)

    def sampleBy(self, col: str, fractions: dict, seed: int | None = None) -> "DataFrame":
        parts = []
        for val, frac in fractions.items():
            part = self._df.filter(pl.col(col) == val).sample(fraction=frac, seed=seed)
            parts.append(part)
        return DataFrame(pl.concat(parts), self._session) if parts else DataFrame(self._df.head(0), self._session)

    # ------------------------------------------------------------------
    # Null handling
    # ------------------------------------------------------------------

    def dropna(self, how: str = "any", thresh: int | None = None,
               subset: list[str] | None = None) -> "DataFrame":
        cols = subset or self._df.columns
        if thresh is not None:
            # Keep rows with at least `thresh` non-null values in cols
            mask = sum(pl.col(c).is_not_null().cast(pl.Int32) for c in cols) >= thresh
            return DataFrame(self._df.filter(mask), self._session)
        if how == "any":
            return DataFrame(self._df.drop_nulls(cols), self._session)
        else:  # 'all'
            mask = pl.any_horizontal([pl.col(c).is_not_null() for c in cols])
            return DataFrame(self._df.filter(mask), self._session)

    def fillna(self, value, subset: list[str] | None = None) -> "DataFrame":
        cols = subset or self._df.columns
        exprs = [pl.col(c).fill_null(value) for c in cols]
        return DataFrame(self._df.with_columns(exprs), self._session)

    def replace(self, to_replace, value=None, subset: list[str] | None = None) -> "DataFrame":
        # Simple scalar replacement
        cols = subset or self._df.columns
        exprs = [
            pl.when(pl.col(c) == to_replace).then(pl.lit(value)).otherwise(pl.col(c)).alias(c)
            for c in cols
        ]
        return DataFrame(self._df.with_columns(exprs), self._session)

    # ------------------------------------------------------------------
    # Actions (materialize)
    # ------------------------------------------------------------------

    def show(self, n: int = 20, truncate: bool | int = True, vertical: bool = False) -> None:
        import sys
        df_show = self._df.head(n)
        output = str(df_show) + "\n"
        # Write as UTF-8 directly to avoid Windows cp1252 issues with Polars table chars
        try:
            sys.stdout.write(output)
        except UnicodeEncodeError:
            sys.stdout.buffer.write(output.encode("utf-8", errors="replace"))
            sys.stdout.buffer.write(b"\n")

    def collect(self) -> list[Row]:
        return _rows_from_polars(self._df)

    def count(self) -> int:
        return len(self._df)

    def first(self) -> Row:
        rows = self.head(1)
        if not rows:
            raise IndexError("DataFrame is empty")
        return rows[0]

    def head(self, n: int = 1) -> list[Row]:
        return _rows_from_polars(self._df.head(n))

    def take(self, n: int) -> list[Row]:
        return self.head(n)

    def toPandas(self):
        return self._df.to_pandas()

    def toPolars(self) -> pl.DataFrame:
        """Non-PySpark helper: get the underlying Polars DataFrame directly."""
        return self._df

    # ------------------------------------------------------------------
    # SQL / views
    # ------------------------------------------------------------------

    def createOrReplaceTempView(self, name: str) -> None:
        self._session.catalog.register(name, self._df)

    def createTempView(self, name: str) -> None:
        if self._session.catalog.tableExists(name):
            raise Exception(f"View '{name}' already exists. Use createOrReplaceTempView.")
        self._session.catalog.register(name, self._df)

    def createOrReplaceGlobalTempView(self, name: str) -> None:
        self.createOrReplaceTempView(name)

    def createGlobalTempView(self, name: str) -> None:
        self.createTempView(name)

    # ------------------------------------------------------------------
    # I/O
    # ------------------------------------------------------------------

    @property
    def write(self) -> "DataFrameWriter":
        from singlespark.writer import DataFrameWriter
        return DataFrameWriter(self)

    @property
    def writeStream(self):
        raise NotImplementedError("Streaming is not supported in singlespark")

    # ------------------------------------------------------------------
    # Cache / persist (no-op — data is already in-memory)
    # ------------------------------------------------------------------

    def cache(self) -> "DataFrame":
        return self

    def persist(self, storageLevel=None) -> "DataFrame":
        return self

    def unpersist(self, blocking: bool = False) -> "DataFrame":
        return self

    # ------------------------------------------------------------------
    # Describe / summary
    # ------------------------------------------------------------------

    def describe(self, *cols) -> "DataFrame":
        target = self._df.select(list(cols)) if cols else self._df
        return DataFrame(target.describe(), self._session)

    def summary(self, *statistics) -> "DataFrame":
        return self.describe()

    # ------------------------------------------------------------------
    # Repartition / coalesce (no-op hints)
    # ------------------------------------------------------------------

    def repartition(self, *args, **kwargs) -> "DataFrame":
        return self

    def coalesce(self, numPartitions: int) -> "DataFrame":
        return self

    def repartitionByRange(self, *args, **kwargs) -> "DataFrame":
        return self

    # ------------------------------------------------------------------
    # Misc
    # ------------------------------------------------------------------

    def na(self):
        """Return a DataFrameNaFunctions proxy (fillna/dropna/replace)."""
        return _NaFunctions(self)

    def stat(self):
        """Return a DataFrameStatFunctions proxy."""
        return _StatFunctions(self)

    def explain(self, extended: bool = False, mode: str | None = None) -> None:
        print("== Physical Plan ==")
        print(f"FakeSpark scan: {self._df.shape[0]} rows x {self._df.shape[1]} cols")

    def isEmpty(self) -> bool:
        return len(self._df) == 0

    def __len__(self):
        return self.count()

    def __repr__(self):
        return f"DataFrame[{', '.join(f'{n}: {t}' for n, t in self.dtypes)}]"

    def __getitem__(self, key):
        if isinstance(key, str):
            return Column(pl.col(key))
        if isinstance(key, int):
            return Column(pl.col(self._df.columns[key]))
        if isinstance(key, Column):
            return self.filter(key)
        if isinstance(key, list):
            return self.select(*key)
        raise TypeError(f"Unsupported key type: {type(key)}")

    def __getattr__(self, name: str):
        if name.startswith("_"):
            raise AttributeError(name)
        if name in self._df.columns:
            return Column(pl.col(name))
        raise AttributeError(f"DataFrame has no column '{name}'")

    def __contains__(self, item: str) -> bool:
        return item in self._df.columns

    def __iter__(self):
        return iter(self._df.columns)


# ---------------------------------------------------------------------------
# Proxy helpers
# ---------------------------------------------------------------------------

class _NaFunctions:
    def __init__(self, df: DataFrame):
        self._df = df

    def drop(self, how="any", thresh=None, subset=None):
        return self._df.dropna(how=how, thresh=thresh, subset=subset)

    def fill(self, value, subset=None):
        return self._df.fillna(value, subset=subset)

    def replace(self, to_replace, value=None, subset=None):
        return self._df.replace(to_replace, value, subset=subset)


class _StatFunctions:
    def __init__(self, df: DataFrame):
        self._df = df

    def corr(self, col1: str, col2: str, method: str = "pearson") -> float:
        return self._df._df.select(pl.corr(col1, col2)).item()

    def cov(self, col1: str, col2: str) -> float:
        return self._df._df.select(pl.cov(col1, col2)).item()

    def crosstab(self, col1: str, col2: str) -> DataFrame:
        result = self._df._df.pivot(index=col1, on=col2, values=col2, aggregate_function="count").fill_null(0)
        return DataFrame(result, self._df._session)

    def freqItems(self, cols, support=0.01) -> DataFrame:
        raise NotImplementedError("freqItems is not yet supported")

    def sampleBy(self, col, fractions, seed=None) -> DataFrame:
        return self._df.sampleBy(col, fractions, seed)


# ---------------------------------------------------------------------------
# Join condition helpers
# ---------------------------------------------------------------------------

import re as _re

_EQ_PATTERN = _re.compile(
    r'^\[\(col\("([^"]+)"\)\)\s*==\s*\(col\("([^"]+)"\)\)\]$'
)


def _extract_equality_join_cols(
    condition: "Column",
    left_cols: list[str],
    right_cols: list[str],
) -> tuple[str | None, str | None]:
    """
    Try to extract (left_col, right_col) from a simple equality join condition
    like  df1["a"] == df2["b"]  which Polars represents as  [(col("a")) == (col("b"))].

    Returns (None, None) if the condition is more complex.
    """
    expr_str = str(condition._expr)
    m = _EQ_PATTERN.match(expr_str)
    if not m:
        return None, None
    col_a, col_b = m.group(1), m.group(2)
    if col_a in left_cols and col_b in right_cols:
        return col_a, col_b
    if col_b in left_cols and col_a in right_cols:
        return col_b, col_a
    return None, None


def _expr_to_sql_str(expr: pl.Expr, left_alias: str, right_alias: str) -> str:
    """
    Best-effort conversion of a Polars Expr to a SQL condition string.
    For complex expressions, users should use spark.sql() directly.
    """
    s = str(expr)
    # Replace Polars col("x") references with table-qualified names
    def _qualify(m):
        name = m.group(1)
        return f'"{name}"'
    s = _re.sub(r'col\("([^"]+)"\)', _qualify, s)
    # Strip outer brackets
    s = s.strip("[]")
    return s
