"""
dxf_to_wgs84_geotiff.py
------------------------
Renders DXF geometry (lines, polylines) directly into a georeferenced
GeoTIFF in WGS84 (EPSG:4326).

Renderiza geometria DXF (linhas, polilinhas) diretamente em um
GeoTIFF georreferenciado em WGS84 (EPSG:4326).

Usage / Uso:
    python dxf_to_wgs84_geotiff.py <input.dxf> [options]

    Options:
        --out        Output filename          (default: output_wgs84.tiff)
        --epsg       SIRGAS 2000 UTM zone     (default: 31982)
                       31981 = Zone 21S
                       31982 = Zone 22S
                       31983 = Zone 23S
                       31984 = Zone 24S
        --res        Resolution in m/pixel    (default: 2.0)

Requirements / Dependências:
    pip install rasterio pyproj ezdxf numpy matplotlib
"""

import argparse
import colorsys
import warnings
warnings.filterwarnings("ignore")

import numpy as np
import ezdxf
from ezdxf.math import Vec3
import matplotlib
matplotlib.use("Agg")          # non-interactive backend / backend sem interface gráfica
import matplotlib.pyplot as plt
import matplotlib.collections as mc
from matplotlib.colors import to_rgba
import rasterio
from rasterio.crs import CRS
from rasterio.transform import from_bounds
from rasterio.warp import calculate_default_transform, reproject, Resampling


# ── Command-line arguments ────────────────────────────────────────────────────
# Argumentos de linha de comando

def parse_args():
    parser = argparse.ArgumentParser(
        description="Convert a SIRGAS 2000 DXF file to a WGS84 GeoTIFF."
    )
    # Positional: source DXF file / Arquivo DXF de entrada (obrigatório)
    parser.add_argument(
        "dxf_in",
        help="Path to the source DXF file (SIRGAS 2000 UTM coordinates)."
    )
    # Optional: output filename / Nome do arquivo de saída
    parser.add_argument(
        "--out", default="output_wgs84.tiff",
        help="Output GeoTIFF filename (default: output_wgs84.tiff)."
    )
    # Optional: EPSG code for source CRS / Código EPSG do CRS de origem
    parser.add_argument(
        "--epsg", type=int, default=31982,
        help="EPSG code for the SIRGAS 2000 UTM zone (default: 31982 = Zone 22S)."
    )
    # Optional: output resolution in metres per pixel / Resolução de saída em metros por pixel
    parser.add_argument(
        "--res", type=float, default=2.0,
        help="Output resolution in metres per pixel (default: 2.0)."
    )
    return parser.parse_args()


# ── AutoCAD Color Index (ACI) → RGB ──────────────────────────────────────────
# Tabela de cores AutoCAD (ACI) → RGB

# Basic ACI colours / Cores básicas ACI
_ACI_TABLE = {
    1: (255,   0,   0),   # red / vermelho
    2: (255, 255,   0),   # yellow / amarelo
    3: (  0, 255,   0),   # green / verde
    4: (  0, 255, 255),   # cyan / ciano
    5: (  0,   0, 255),   # blue / azul
    6: (255,   0, 255),   # magenta
    7: (255, 255, 255),   # white / branco
    8: (128, 128, 128),   # dark grey / cinza escuro
    9: (192, 192, 192),   # light grey / cinza claro
}

def aci_to_rgb(aci: int) -> tuple:
    """
    Convert an AutoCAD Color Index value to a normalised (R, G, B) tuple.
    Converte um valor ACI do AutoCAD em uma tupla (R, G, B) normalizada (0–1).
    """
    if aci in _ACI_TABLE:
        r, g, b = _ACI_TABLE[aci]
        return r / 255, g / 255, b / 255
    # For indices outside the basic table, spread across the HSV hue wheel.
    # Para índices fora da tabela básica, distribui pela roda de matiz HSV.
    hue = ((max(1, min(aci, 255)) - 10) % 240) / 240.0
    return colorsys.hsv_to_rgb(hue, 1.0, 1.0)


def resolve_entity_color(entity, doc) -> tuple:
    """
    Resolve the display colour of an entity, respecting ByLayer / ByBlock
    inheritance rules.
    Resolve a cor de exibição de uma entidade, respeitando as regras de
    herança ByLayer / ByBlock.
    """
    layer_name = entity.dxf.layer if entity.dxf.hasattr("layer") else "0"

    aci = entity.dxf.color if entity.dxf.hasattr("color") else 256

    # ByLayer (256): inherit colour from the layer definition
    # ByLayer (256): herda a cor da definição da camada
    if aci == 256:
        layer = doc.layers.get(layer_name)
        aci   = layer.dxf.color if (layer and layer.dxf.hasattr("color")) else 7

    # ByBlock (0): fall back to white
    # ByBlock (0): usa branco como fallback
    if aci == 0:
        aci = 7

    return aci_to_rgb(aci)


# ── Geometry extraction ───────────────────────────────────────────────────────
# Extração de geometria

def polyline_segments(pts: list) -> list:
    """
    Convert a list of 2-D points into consecutive (start, end) segment pairs.
    Converte uma lista de pontos 2D em pares de segmentos consecutivos (início, fim).
    """
    return [
        ((pts[i].x, pts[i].y), (pts[i + 1].x, pts[i + 1].y))
        for i in range(len(pts) - 1)
    ]


def extract_segments(msp, doc) -> dict:
    """
    Walk every entity in model space and collect line segments grouped by colour.
    Percorre todas as entidades no espaço do modelo e coleta segmentos de linha
    agrupados por cor.
    Returns / Retorna: {(r, g, b): [(start, end), ...]}
    """
    lines_by_color: dict = {}

    for entity in msp:
        entity_type = entity.dxftype()
        try:
            color = resolve_entity_color(entity, doc)
            segs  = []

            if entity_type == "LINE":
                # Simple two-point line / Linha simples de dois pontos
                s, e = entity.dxf.start, entity.dxf.end
                segs = [((s.x, s.y), (e.x, e.y))]

            elif entity_type == "LWPOLYLINE":
                # Lightweight polyline (2-D) / Polilinha leve (2D)
                pts  = [Vec3(p[0], p[1], 0) for p in entity.get_points()]
                segs = polyline_segments(pts)

            elif entity_type == "POLYLINE":
                # Classic 3-D polyline — use only XY / Polilinha clássica 3D — usa apenas XY
                pts  = [v.dxf.location for v in entity.vertices]
                segs = polyline_segments(pts)

            if segs:
                lines_by_color.setdefault(color, []).extend(segs)

        except Exception:
            # Skip entities that cannot be processed / Ignora entidades que não podem ser processadas
            pass

    return lines_by_color


# ── Rendering ─────────────────────────────────────────────────────────────────
# Renderização

def render_to_array(lines_by_color: dict, xmin, ymin, xmax, ymax,
                    resolution: float) -> np.ndarray:
    """
    Render line segments onto a white canvas using Matplotlib and return the
    result as an RGBA numpy array shaped (4, height, width).
    Renderiza segmentos de linha em uma tela branca usando Matplotlib e retorna
    o resultado como um array NumPy RGBA com forma (4, altura, largura).
    """
    # Calculate canvas size in pixels from real-world extent and resolution.
    # Calcula o tamanho da tela em pixels a partir da extensão real e da resolução.
    px_w  = int((xmax - xmin) / resolution)
    px_h  = int((ymax - ymin) / resolution)
    dpi   = 100
    print(f"  Canvas size : {px_w} x {px_h} px  ({resolution} m/px)")

    fig, ax = plt.subplots(figsize=(px_w / dpi, px_h / dpi), dpi=dpi)
    fig.patch.set_facecolor("white")
    ax.set_facecolor("white")
    ax.set_xlim(xmin, xmax)
    ax.set_ylim(ymin, ymax)
    ax.set_aspect("equal")
    ax.axis("off")
    fig.subplots_adjust(left=0, right=1, bottom=0, top=1)

    # Add one LineCollection per colour for efficiency.
    # Adiciona uma LineCollection por cor para eficiência.
    for color, segs in lines_by_color.items():
        lc = mc.LineCollection(segs, colors=[(*color, 1.0)], linewidths=0.5)
        ax.add_collection(lc)

    fig.canvas.draw()
    buf = np.frombuffer(fig.canvas.buffer_rgba(), dtype=np.uint8)
    buf = buf.reshape(fig.canvas.get_width_height()[::-1] + (4,))
    plt.close(fig)

    # Rearrange from (H, W, 4) to (4, H, W) as expected by rasterio.
    # Reorganiza de (H, W, 4) para (4, H, W) conforme esperado pelo rasterio.
    return np.moveaxis(buf, -1, 0)


# ── CRS assignment & reprojection ─────────────────────────────────────────────
# Atribuição de CRS e reprojeção

def reproject_to_wgs84(img_arr: np.ndarray, xmin, ymin, xmax, ymax,
                        src_epsg: int) -> tuple:
    """
    Assign a SIRGAS 2000 UTM CRS to the rendered array and reproject it to
    WGS84 (EPSG:4326).
    Atribui um CRS UTM SIRGAS 2000 ao array renderizado e o reprojeta para
    WGS84 (EPSG:4326).
    Returns / Retorna: (reprojected_array, dst_transform, dst_crs)
    """
    _, h, w = img_arr.shape
    src_crs       = CRS.from_epsg(src_epsg)
    src_transform = from_bounds(xmin, ymin, xmax, ymax, w, h)

    dst_crs = CRS.from_epsg(4326)
    dst_transform, dst_w, dst_h = calculate_default_transform(
        src_crs, dst_crs, w, h,
        left=xmin, bottom=ymin, right=xmax, top=ymax,
    )

    dst_data = np.zeros((4, dst_h, dst_w), dtype=np.uint8)
    for i in range(4):
        reproject(
            source        = img_arr[i],
            destination   = dst_data[i],
            src_transform = src_transform,
            src_crs       = src_crs,
            dst_transform = dst_transform,
            dst_crs       = dst_crs,
            resampling    = Resampling.lanczos,
        )

    return dst_data, dst_transform, dst_crs


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    args = parse_args()

    # ── 1. Load DXF and read bounding box from header
    # ── 1. Carrega o DXF e lê a caixa delimitadora do cabeçalho
    print(f"Loading DXF: {args.dxf_in}")
    doc = ezdxf.readfile(args.dxf_in)
    msp = doc.modelspace()

    xmin = doc.header["$EXTMIN"][0]
    ymin = doc.header["$EXTMIN"][1]
    xmax = doc.header["$EXTMAX"][0]
    ymax = doc.header["$EXTMAX"][1]
    print(f"  Bounding box : ({xmin:.2f}, {ymin:.2f}) -> ({xmax:.2f}, {ymax:.2f})")
    print(f"  Extent       : {xmax - xmin:.1f} m x {ymax - ymin:.1f} m")
    print(f"  Source CRS   : EPSG:{args.epsg}")

    # ── 2. Extract geometry from model space
    # ── 2. Extrai geometria do espaço do modelo
    print("Extracting geometry ...")
    lines_by_color = extract_segments(msp, doc)
    total = sum(len(v) for v in lines_by_color.values())
    print(f"  {total} segments across {len(lines_by_color)} colour(s)")

    # ── 3. Render to in-memory RGBA array
    # ── 3. Renderiza em um array RGBA na memória
    print("Rendering ...")
    img_arr = render_to_array(lines_by_color, xmin, ymin, xmax, ymax, args.res)
    print(f"  Rendered     : {img_arr.shape[2]} x {img_arr.shape[1]} px")

    # ── 4. Assign SIRGAS 2000 CRS and reproject to WGS84
    # ── 4. Atribui o CRS SIRGAS 2000 e reprojeta para WGS84
    print("Reprojecting to WGS84 ...")
    dst_data, dst_transform, dst_crs = reproject_to_wgs84(
        img_arr, xmin, ymin, xmax, ymax, args.epsg
    )

    # ── 5. Write output GeoTIFF (LZW compressed)
    # ── 5. Grava o GeoTIFF de saída (comprimido com LZW)
    _, dst_h, dst_w = dst_data.shape
    with rasterio.open(args.out, "w",
            driver    = "GTiff",
            dtype     = "uint8",
            width     = dst_w,
            height    = dst_h,
            count     = 4,
            crs       = dst_crs,
            transform = dst_transform,
            compress  = "lzw") as out_file:
        out_file.write(dst_data)

    # ── 6. Print summary
    # ── 6. Exibe resumo
    print(f"\nSaved: {args.out}")
    with rasterio.open(args.out) as v:
        b = v.bounds
        print(f"  CRS          : {v.crs}")
        print(f"  Longitude    : {b.left:.6f} -> {b.right:.6f}")
        print(f"  Latitude     : {b.bottom:.6f} -> {b.top:.6f}")
        print(f"  Size         : {v.width} x {v.height} px")


if __name__ == "__main__":
    main()
