# Licensed under a 3-clause BSD style license - see LICENSE.rst
"""
``fitsheader`` is a command line script based on astropy.io.fits for printing
the header(s) of one or more FITS file(s) to the standard output in a human-
readable format.

Example uses of fitsheader:

1. Print the header of all the HDUs of a .fits file::

    $ fitsheader filename.fits

2. Print the header of the third and fifth HDU extension::

    $ fitsheader --extension 3 --extension 5 filename.fits

3. Print the header of a named extension, e.g. select the HDU containing
   keywords EXTNAME='SCI' and EXTVER='2'::

    $ fitsheader --extension "SCI,2" filename.fits

4. Print only specific keywords::

    $ fitsheader --keyword BITPIX --keyword NAXIS filename.fits

5. Print keywords NAXIS, NAXIS1, NAXIS2, etc using a wildcard::

    $ fitsheader --keyword NAXIS* filename.fits

6. Dump the header keywords of all the files in the current directory into a
   machine-readable csv file::

    $ fitsheader --table ascii.csv *.fits > keywords.csv

7. Specify hierarchical keywords with the dotted or spaced notation::

    $ fitsheader --keyword ESO.INS.ID filename.fits
    $ fitsheader --keyword "ESO INS ID" filename.fits

8. Compare the headers of different fits files, following ESO's ``fitsort``
   format::

    $ fitsheader --fitsort --extension 0 --keyword ESO.INS.ID *.fits

9. Same as above, sorting the output along a specified keyword::

    $ fitsheader -f -s DATE-OBS -e 0 -k DATE-OBS -k ESO.INS.ID *.fits

10. Sort first by OBJECT, then DATE-OBS::

    $ fitsheader -f -s OBJECT -s DATE-OBS *.fits

Note that compressed images (HDUs of type
:class:`~astropy.io.fits.CompImageHDU`) really have two headers: a real
BINTABLE header to describe the compressed data, and a fake IMAGE header
representing the image that was compressed. Astropy returns the latter by
default. You must supply the ``--compressed`` option if you require the real
header that describes the compression.

With Astropy installed, please run ``fitsheader --help`` to see the full usage
documentation.
"""

import argparse
import sys

import numpy as np

from astropy import __version__, log
from astropy.io import fits
from astropy.io.fits import CompImageHDU

DESCRIPTION = """
Print the header(s) of a FITS file. Optional arguments allow the desired
extension(s), keyword(s), and output format to be specified.
Note that in the case of a compressed image, the decompressed header is
shown by default.

This script is part of the Astropy package. See
https://docs.astropy.org/en/latest/io/fits/usage/scripts.html#module-astropy.io.fits.scripts.fitsheader
for further documentation.
""".strip()


class ExtensionNotFoundException(Exception):
    """Raised if an HDU extension requested by the user does not exist."""


class HeaderFormatter:
    """Class to format the header(s) of a FITS file for display by the
    `fitsheader` tool; essentially a wrapper around a `HDUList` object.

    Example usage:
    fmt = HeaderFormatter('/path/to/file.fits')
    print(fmt.parse(extensions=[0, 3], keywords=['NAXIS', 'BITPIX']))

    Parameters
    ----------
    filename : str
        Path to a single FITS file.
    verbose : bool
        Verbose flag, to show more information about missing extensions,
        keywords, etc.

    Raises
    ------
    OSError
        If `filename` does not exist or cannot be read.
    """

    def __init__(self, filename, verbose=True):
        self.filename = filename
        self.verbose = verbose
        self._hdulist = fits.open(filename)

    def parse(self, extensions=None, keywords=None, compressed=False):
        """Returns the FITS file header(s) in a readable format.

        Parameters
        ----------
        extensions : list of int or str, optional
            Format only specific HDU(s), identified by number or name.
            The name can be composed of the "EXTNAME" or "EXTNAME,EXTVER"
            keywords.

        keywords : list of str, optional
            Keywords for which the value(s) should be returned.
            If not specified, then the entire header is returned.

        compressed : bool, optional
            If True, shows the header describing the compression, rather than
            the header obtained after decompression. (Affects FITS files
            containing `CompImageHDU` extensions only.)

        Returns
        -------
        formatted_header : str or astropy.table.Table
            Traditional 80-char wide format in the case of `HeaderFormatter`;
            an Astropy Table object in the case of `TableHeaderFormatter`.
        """
        # `hdukeys` will hold the keys of the HDUList items to display
        if extensions is None:
            hdukeys = range(len(self._hdulist))  # Display all by default
        else:
            hdukeys = []
            for ext in extensions:
                try:
                    # HDU may be specified by number
                    hdukeys.append(int(ext))
                except ValueError:
                    # The user can specify "EXTNAME" or "EXTNAME,EXTVER"
                    parts = ext.split(",")
                    if len(parts) > 1:
                        extname = ",".join(parts[0:-1])
                        extver = int(parts[-1])
                        hdukeys.append((extname, extver))
                    else:
                        hdukeys.append(ext)

        # Having established which HDUs the user wants, we now format these:
        return self._parse_internal(hdukeys, keywords, compressed)

    def _parse_internal(self, hdukeys, keywords, compressed):
        """The meat of the formatting; in a separate method to allow overriding."""
        result = []
        for idx, hdu in enumerate(hdukeys):
            try:
                cards = self._get_cards(hdu, keywords, compressed)
            except ExtensionNotFoundException:
                continue

            if idx > 0:  # Separate HDUs by a blank line
                result.append("\n")
            result.append(f"# HDU {hdu} in {self.filename}:\n")
            for c in cards:
                result.append(f"{c}\n")
        return "".join(result)

    def _get_cards(self, hdukey, keywords, compressed):
        """Returns a list of `astropy.io.fits.card.Card` objects.

        This function will return the desired header cards, taking into
        account the user's preference to see the compressed or uncompressed
        version.

        Parameters
        ----------
        hdukey : int or str
            Key of a single HDU in the HDUList.

        keywords : list of str, optional
            Keywords for which the cards should be returned.

        compressed : bool, optional
            If True, shows the header describing the compression.

        Raises
        ------
        ExtensionNotFoundException
            If the hdukey does not correspond to an extension.
        """
        # First we obtain the desired header
        try:
            if compressed and isinstance(self._hdulist[hdukey], CompImageHDU):
                # In the case of a compressed image, return the header before
                # decompression (not the default behavior)
                header = self._hdulist[hdukey]._bintable.header
            else:
                header = self._hdulist[hdukey].header
        except (IndexError, KeyError):
            message = f"{self.filename}: Extension {hdukey} not found."
            if self.verbose:
                log.warning(message)
            raise ExtensionNotFoundException(message)

        if not keywords:  # return all cards
            cards = header.cards
        else:  # specific keywords are requested
            cards = []
            for kw in keywords:
                try:
                    crd = header.cards[kw]
                    if isinstance(crd, fits.card.Card):  # Single card
                        cards.append(crd)
                    else:  # Allow for wildcard access
                        cards.extend(crd)
                except KeyError:  # Keyword does not exist
                    if self.verbose:
                        log.warning(
                            f"{self.filename} (HDU {hdukey}): Keyword {kw} not found."
                        )
        return cards

    def close(self):
        self._hdulist.close()


class TableHeaderFormatter(HeaderFormatter):
    """Class to convert the header(s) of a FITS file into a Table object.
    The table returned by the `parse` method will contain four columns:
    filename, hdu, keyword, and value.

    Subclassed from HeaderFormatter, which contains the meat of the formatting.
    """

    def _parse_internal(self, hdukeys, keywords, compressed):
        """Method called by the parse method in the parent class."""
        tablerows = []
        for hdu in hdukeys:
            try:
                for card in self._get_cards(hdu, keywords, compressed):
                    tablerows.append(
                        {
                            "filename": self.filename,
                            "hdu": hdu,
                            "keyword": card.keyword,
                            "value": str(card.value),
                        }
                    )
            except ExtensionNotFoundException:
                pass

        if tablerows:
            from astropy import table

            return table.Table(tablerows)
        return None


def print_headers_traditional(args):
    """Prints FITS header(s) using the traditional 80-char format.

    Parameters
    ----------
    args : argparse.Namespace
        Arguments passed from the command-line as defined below.
    """
    for idx, filename in enumerate(args.filename):  # support wildcards
        if idx > 0 and not args.keyword:
            print()  # print a newline between different files

        formatter = None
        try:
            formatter = HeaderFormatter(filename)
            print(
                formatter.parse(args.extensions, args.keyword, args.compressed), end=""
            )
        except OSError as e:
            log.error(str(e))
        finally:
            if formatter:
                formatter.close()


def print_headers_as_table(args):
    """Prints FITS header(s) in a machine-readable table format.

    Parameters
    ----------
    args : argparse.Namespace
        Arguments passed from the command-line as defined below.
    """
    tables = []
    # Create a Table object for each file
    for filename in args.filename:  # Support wildcards
        formatter = None
        try:
            formatter = TableHeaderFormatter(filename)
            tbl = formatter.parse(args.extensions, args.keyword, args.compressed)
            if tbl:
                tables.append(tbl)
        except OSError as e:
            log.error(str(e))  # file not found or unreadable
        finally:
            if formatter:
                formatter.close()

    # Concatenate the tables
    if len(tables) == 0:
        return False
    elif len(tables) == 1:
        resulting_table = tables[0]
    else:
        from astropy import table

        resulting_table = table.vstack(tables)
    # Print the string representation of the concatenated table
    resulting_table.write(sys.stdout, format=args.table)


def print_headers_as_comparison(args):
    """Prints FITS header(s) with keywords as columns.

    This follows the dfits+fitsort format.

    Parameters
    ----------
    args : argparse.Namespace
        Arguments passed from the command-line as defined below.
    """
    from astropy import table

    tables = []
    # Create a Table object for each file
    for filename in args.filename:  # Support wildcards
        formatter = None
        try:
            formatter = TableHeaderFormatter(filename, verbose=False)
            tbl = formatter.parse(args.extensions, args.keyword, args.compressed)
            if tbl:
                # Remove empty keywords
                tbl = tbl[np.where(tbl["keyword"] != "")]
            else:
                tbl = table.Table([[filename]], names=("filename",))
            tables.append(tbl)
        except OSError as e:
            log.error(str(e))  # file not found or unreadable
        finally:
            if formatter:
                formatter.close()

    # Concatenate the tables
    if len(tables) == 0:
        return False
    elif len(tables) == 1:
        resulting_table = tables[0]
    else:
        resulting_table = table.vstack(tables)

    # If we obtained more than one hdu, merge hdu and keywords columns
    hdus = resulting_table["hdu"]
    if np.ma.isMaskedArray(hdus):
        hdus = hdus.compressed()
    if len(np.unique(hdus)) > 1:
        for tab in tables:
            new_column = table.Column([f"{row['hdu']}:{row['keyword']}" for row in tab])
            tab.add_column(new_column, name="hdu+keyword")
        keyword_column_name = "hdu+keyword"
    else:
        keyword_column_name = "keyword"

    # Check how many hdus we are processing
    final_tables = []
    for tab in tables:
        final_table = [table.Column([tab["filename"][0]], name="filename")]
        if "value" in tab.colnames:
            for row in tab:
                if row["keyword"] in ("COMMENT", "HISTORY"):
                    continue
                final_table.append(
                    table.Column([row["value"]], name=row[keyword_column_name])
                )
        final_tables.append(table.Table(final_table))
    final_table = table.vstack(final_tables)
    # Sort if requested
    if args.sort:
        final_table.sort(args.sort)
    # Reorganise to keyword by columns
    final_table.pprint(max_lines=-1, max_width=-1)


def main(args=None):
    """This is the main function called by the `fitsheader` script."""
    parser = argparse.ArgumentParser(
        description=DESCRIPTION, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    # TODO: pass suggest_on_error as kwarg when PYTHON_LT_14 is dropped
    parser.suggest_on_error = True

    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {__version__}"
    )

    parser.add_argument(
        "-e",
        "--extension",
        metavar="HDU",
        action="append",
        dest="extensions",
        help=(
            "specify the extension by name or number; this argument can "
            "be repeated to select multiple extensions"
        ),
    )
    parser.add_argument(
        "-k",
        "--keyword",
        metavar="KEYWORD",
        action="append",
        type=str,
        help=(
            "specify a keyword; this argument can be repeated to select "
            "multiple keywords; also supports wildcards"
        ),
    )
    mode_group = parser.add_mutually_exclusive_group()
    mode_group.add_argument(
        "-t",
        "--table",
        nargs="?",
        default=False,
        metavar="FORMAT",
        help=(
            "print the header(s) in machine-readable table format; the "
            'default format is "ascii.fixed_width" (can be "ascii.csv", '
            '"ascii.html", "ascii.latex", "fits", etc)'
        ),
    )
    mode_group.add_argument(
        "-f",
        "--fitsort",
        action="store_true",
        help=(
            "print the headers as a table with each unique "
            "keyword in a given column (fitsort format) "
        ),
    )
    parser.add_argument(
        "-s",
        "--sort",
        metavar="SORT_KEYWORD",
        action="append",
        type=str,
        help=(
            "sort output by the specified header keywords, can be repeated to "
            "sort by multiple keywords; Only supported with -f/--fitsort"
        ),
    )
    parser.add_argument(
        "-c",
        "--compressed",
        action="store_true",
        help=(
            "for compressed image data, show the true header which describes "
            "the compression rather than the data"
        ),
    )
    parser.add_argument(
        "filename",
        nargs="+",
        help="path to one or more files; wildcards are supported",
    )
    args = parser.parse_args(args)

    # If `--table` was used but no format specified,
    # then use ascii.fixed_width by default
    if args.table is None:
        args.table = "ascii.fixed_width"

    if args.sort:
        args.sort = [key.replace(".", " ") for key in args.sort]
        if not args.fitsort:
            log.error(
                "Sorting with -s/--sort is only supported in conjunction with"
                " -f/--fitsort"
            )
            # 2: Unix error convention for command line syntax
            sys.exit(2)

    if args.keyword:
        args.keyword = [key.replace(".", " ") for key in args.keyword]

    # Now print the desired headers
    try:
        if args.table:
            print_headers_as_table(args)
        elif args.fitsort:
            print_headers_as_comparison(args)
        else:
            print_headers_traditional(args)
    except OSError:
        # A 'Broken pipe' OSError may occur when stdout is closed prematurely,
        # eg. when calling `fitsheader file.fits | head`. We let this pass.
        pass
