# ruff: noqa: N802, N806, N816, N999, SIM115

import datetime
import os
import struct
import time

from ...common import MISSINGVALUE
from .. import tables

__author__ = "Charles R Schmidt <schmidtc@gmail.com>"
__all__ = ["DBF"]


class DBF(tables.DataTable):
    """PySAL DBF Reader/Writer. This DBF handler implements the PySAL DataTable
    interface and initializes an instance of the PySAL's DBF handler.

    Parameters
    -----------
    dataPath : str
        Path to file, including file name and extension.
    mode : str
        Mode for file interaction; either ``'r'`` or ``'w'``.

    Attributes
    ----------
    header : list
        A list of field names. The header is a python list of strings. Each string
        is a field name and field name must not be longer than 10 characters.
    field_spec : list
        A list describing the data types of each field. It is comprised of a list
        of tuples, each tuple describing a field. The format for the tuples is
        ``('Type', len, precision)``. Valid values for ``'Type'`` are ``'C'``
        for characters, ``'L'`` for bool, ``'D'`` for data, and ``'N'`` or
        ``'F'`` for number.

    Examples
    --------

    >>> import libpysal
    >>> dbf = libpysal.io.open(libpysal.examples.get_path('juvenile.dbf'), 'r')
    >>> dbf.header
    ['ID', 'X', 'Y']

    >>> dbf.field_spec
    [('N', 9, 0), ('N', 9, 0), ('N', 9, 0)]

    """

    FORMATS = ["dbf"]
    MODES = ["r", "w"]

    def __init__(self, *args, **kwargs):
        tables.DataTable.__init__(self, *args, **kwargs)

        if self.mode == "r":
            self.f = f = open(self.dataPath, "rb")
            # from dbf file standards
            numrec, lenheader = struct.unpack("<xxxxLH22x", f.read(32))

            # each field is 32 bytes
            numfields = (lenheader - 33) // 32
            self.n_records = numrec
            self.n_fields = numfields
            self.field_info = [("DeletionFlag", "C", 1, 0)]
            record_size = 1

            # each record is a string
            fmt = "s"

            self._col_index = {}
            idx = 0

            for _ in range(numfields):
                # again, check struct for fmt def.
                name, typ, size, deci = struct.unpack("<11sc4xBB14x", f.read(32))
                # forces to unicode in 2, to str in 3
                name = name.decode()
                typ = typ.decode()
                # same as NULs, \x00
                name = name.replace("\0", "")
                # eliminate NULs from string
                self._col_index[name] = (idx, record_size)
                idx += 1
                # alt: str(size) + 's'
                fmt += "%ds" % size

                record_size += size
                self.field_info.append((name, typ, size, deci))

            terminator = f.read(1).decode()

            assert terminator == "\r"
            self.header_size = self.f.tell()
            self.record_size = record_size
            self.record_fmt = fmt
            self.pos = 0
            self.header = [fInfo[0] for fInfo in self.field_info[1:]]
            field_spec = []

            for _, ftype, flen, fpre in self.field_info[1:]:
                field_spec.append((ftype, flen, fpre))

            self.field_spec = field_spec

            # self.spec = [types[fInfo[0]] for fInfo in self.field_info]

        elif self.mode == "w":
            self.f = open(self.dataPath, "wb")
            self.header = None
            self.field_spec = None
            self.numrec = 0
            self.FIRST_WRITE = True

    def __len__(self) -> int:
        """

        Raises
        ------
        IOError
            Raised when a file is open ``'w'`` mode.

        """

        if self.mode != "r":
            msg = "Invalid operation, cannot read from a file opened in 'w' mode."
            raise OSError(msg)

        return self.n_records

    def seek(self, i):
        self.f.seek(self.header_size + (self.record_size * i))
        self.pos = i

    def _get_col(self, key: str) -> list:
        """Return the column vector.

        Raises
        ------
        AttributeError
            Raised when a field does not exist in the header.

        """

        if key not in self._col_index:
            raise AttributeError("Field: %s does not exist in header." % key)

        prevPos = self.tell()
        idx, offset = self._col_index[key]
        typ, size, deci = self.field_spec[idx]
        gap = self.record_size - size
        f = self.f
        f.seek(self.header_size + offset)
        col = [0] * self.n_records

        for i in range(self.n_records):
            value = f.read(size)
            value = value.decode()
            f.seek(gap, 1)

            if typ == "N":
                value = value.replace("\0", "").lstrip()
                if value == "":
                    value = MISSINGVALUE
                elif deci:
                    try:
                        value = float(value)
                    except ValueError:
                        value = MISSINGVALUE
                else:
                    try:
                        value = int(value)
                    except ValueError:
                        value = MISSINGVALUE
            elif typ == "D":
                try:
                    y, m, d = int(value[:4]), int(value[4:6]), int(value[6:8])
                    value = datetime.date(y, m, d)
                except ValueError:
                    value = MISSINGVALUE
            elif typ == "L":
                value = (value in "YyTt" and "T") or (value in "NnFf" and "F") or "?"
            elif typ == "F":
                value = value.replace("\0", "").lstrip()
                value = MISSINGVALUE if value == "" else float(value)
            if isinstance(value, str | str):
                value = value.rstrip()
            col[i] = value

        self.seek(prevPos)

        return col

    def read_record(self, i: int) -> list:
        self.seek(i)

        rec = list(struct.unpack(self.record_fmt, self.f.read(self.record_size)))
        rec = [entry.decode() for entry in rec]

        if rec[0] != " ":
            return self.read_record(i + 1)
        result = []

        for (name, typ, _, deci), value in zip(self.field_info, rec, strict=True):
            if name == "DeletionFlag":
                continue
            if typ == "N":
                value = value.replace("\0", "").lstrip()
                if value == "":
                    value = MISSINGVALUE
                elif deci:
                    try:
                        value = float(value)
                    except ValueError:
                        value = MISSINGVALUE
                else:
                    try:
                        value = int(value)
                    except ValueError:
                        value = MISSINGVALUE
            elif typ == "D":
                try:
                    y, m, d = int(value[:4]), int(value[4:6]), int(value[6:8])
                    value = datetime.date(y, m, d)
                except ValueError:
                    # value = datetime.date.min#NULL Date: See issue 114
                    value = MISSINGVALUE
            elif typ == "L":
                value = (value in "YyTt" and "T") or (value in "NnFf" and "F") or "?"
            elif typ == "F":
                value = value.replace("\0", "").lstrip()
                value = MISSINGVALUE if value == "" else float(value)
            if isinstance(value, str | str):
                value = value.rstrip()
            result.append(value)

        return result

    def _read(self) -> list | None:
        """

        Raises
        ------
        IOError
            Raised when a file is open ``'w'`` mode.

        """

        if self.mode != "r":
            msg = "Invalid operation, cannot read from a file opened in 'w' mode."
            raise OSError(msg)

        if self.pos < len(self):
            rec = self.read_record(self.pos)
            self.pos += 1
            return rec
        else:
            return None

    def write(self, obj: list):
        """

        Raises
        ------
        IOError
            Raised when a file is open ``'r'`` mode.
        TypeError
            Raised when a row length and header length are not equivalent.

        """

        self._complain_ifclosed(self.closed)

        if self.mode != "w":
            msg = "Invalid operation, cannot read from a file opened in 'r' mode."
            raise OSError(msg)

        if self.FIRST_WRITE:
            self._firstWrite()

        if len(obj) != len(self.header):
            raise TypeError("Rows must contains %d fields." % len(self.header))

        self.numrec += 1

        # deletion flag
        self.f.write(b" ")

        for (typ, size, deci), value in zip(self.field_spec, obj, strict=True):
            if value is None:
                value = " " * size if typ == "C" else "\x00" * size
            elif typ == "N" or typ == "F":
                # v = str(value).rjust(size, " ")
                # # if len(v) == size:
                # #    value = v
                # # else:
                value = (("%" + "%d.%d" % (size, deci) + "f") % (value))[:size]
            elif typ == "D":
                value = value.strftime("%Y%m%d")
            elif typ == "L":
                value = str(value)[0].upper()
            else:
                value = str(value)[:size].ljust(size, " ")
            try:
                assert len(value) == size
            except:
                print(value, len(value), size)
                raise
            self.f.write(value.encode())
            self.pos += 1

    def flush(self):
        self._complain_ifclosed(self.closed)
        self._writeHeader()
        self.f.flush()

    def close(self):
        if self.mode == "w":
            self.flush()
            # End of file
            self.f.write(b"\x1A")
        self.f.close()

        tables.DataTable.close(self)

    def _firstWrite(self):
        """

        Raises
        ------
        IOError
            Raised when there is no specified header.
        IOError
            Raised when there is no field specification.

        """

        if not self.header:
            raise OSError("No header, DBF files require a header.")
        if not self.field_spec:
            raise OSError("No field_spec, DBF files require a specification.")

        self._writeHeader()

        self.FIRST_WRITE = False

    def _writeHeader(self):
        """Modified from:
        http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/362715
        """

        POS = self.f.tell()
        self.f.seek(0)
        ver = 3
        now = datetime.datetime.utcfromtimestamp(
            int(os.environ.get("SOURCE_DATE_EPOCH", time.time())),
        )

        yr, mon, day = now.year - 1900, now.month, now.day
        numrec = self.numrec
        numfields = len(self.header)
        lenheader = numfields * 32 + 33
        lenrecord = sum(field[1] for field in self.field_spec) + 1

        hdr = struct.pack(
            "<BBBBLHH20x", ver, yr, mon, day, numrec, lenheader, lenrecord
        )
        self.f.write(hdr)

        # field specs
        for name, (typ, size, deci) in zip(self.header, self.field_spec, strict=True):
            typ = typ.encode()
            name = name.ljust(11, "\x00")
            name = name.encode()
            fld = struct.pack("<11sc4xBB14x", name, typ, size, deci)
            self.f.write(fld)

        # terminator
        term = b"\r"
        self.f.write(term)
        if self.f.tell() != POS and not self.FIRST_WRITE:
            self.f.seek(POS)


if __name__ == "__main__":
    import libpysal

    file_name = libpysal.examples.get_path("10740.dbf")
    f = libpysal.open(file_name, "r")
    newDB = libpysal.open("copy.dbf", "w")

    newDB.header = f.header
    newDB.field_spec = f.field_spec
    print(f.header)
    for row in f:
        print(row)
        newDB.write(row)
    newDB.close()
    copy = libpysal.open("copy.dbf", "r")

    f.seek(0)
    print("HEADER: ", copy.header == f.header)
    print("SPEC: ", copy.field_spec == f.field_spec)
    print("DATA: ", list(copy) == list(f))
