# 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 fites 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 DATE-OBS -e 0 -k DATE-OBS -k ESO.INS.ID *.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 sys
import argparse

import numpy as np

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


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."""
    pass


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:
                # In the case of a compressed image, return the header before
                # decompression (not the default behavior)
                header = self._hdulist[hdukey]._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('{filename} (HDU {hdukey}): '
                                    'Keyword {kw} not found.'.format(
                                        filename=self.filename,
                                        hdukey=hdukey,
                                        kw=kw))
        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.keywords:
            print()  # print a newline between different files

        formatter = None
        try:
            formatter = HeaderFormatter(filename)
            print(formatter.parse(args.extensions,
                                  args.keywords,
                                  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.keywords,
                                  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.keywords,
                                  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.fitsort is not True:  # then it must be a keyword, therefore sort
        final_table.sort(args.fitsort)
    # Reorganise to keyword by columns
    final_table.pprint(max_lines=-1, max_width=-1)


class KeywordAppendAction(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        keyword = values.replace('.', ' ')
        if namespace.keywords is None:
            namespace.keywords = []
        if keyword not in namespace.keywords:
            namespace.keywords.append(keyword)


def main(args=None):
    """This is the main function called by the `fitsheader` script."""

    parser = argparse.ArgumentParser(
        description=DESCRIPTION,
        formatter_class=argparse.RawDescriptionHelpFormatter)

    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=KeywordAppendAction, dest='keywords',
                        help='specify a keyword; this argument can be '
                             'repeated to select multiple keywords; '
                             'also supports wildcards')
    parser.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)')
    parser.add_argument('-f', '--fitsort', action='store_true',
                        help='print the headers as a table with each unique '
                             'keyword in a given column (fitsort format); '
                             'if a SORT_KEYWORD is specified, the result will be '
                             'sorted along that keyword')
    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'

    # 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
