<?php

declare(strict_types=1);

namespace OpenSpout\Reader\ODS;

use DOMElement;
use OpenSpout\Common\Exception\IOException;
use OpenSpout\Common\Helper\Escaper\ODS;
use OpenSpout\Reader\Common\XMLProcessor;
use OpenSpout\Reader\Exception\XMLProcessingException;
use OpenSpout\Reader\ODS\Helper\CellValueFormatter;
use OpenSpout\Reader\ODS\Helper\SettingsHelper;
use OpenSpout\Reader\SheetIteratorInterface;
use OpenSpout\Reader\Wrapper\XMLReader;

/**
 * @implements SheetIteratorInterface<Sheet>
 */
final class SheetIterator implements SheetIteratorInterface
{
    public const CONTENT_XML_FILE_PATH = 'content.xml';

    public const XML_STYLE_NAMESPACE = 'urn:oasis:names:tc:opendocument:xmlns:style:1.0';

    /**
     * Definition of XML nodes name and attribute used to parse sheet data.
     */
    public const XML_NODE_AUTOMATIC_STYLES = 'office:automatic-styles';
    public const XML_NODE_STYLE_TABLE_PROPERTIES = 'table-properties';
    public const XML_NODE_TABLE = 'table:table';
    public const XML_ATTRIBUTE_STYLE_NAME = 'style:name';
    public const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
    public const XML_ATTRIBUTE_TABLE_STYLE_NAME = 'table:style-name';
    public const XML_ATTRIBUTE_TABLE_DISPLAY = 'table:display';

    /** @var string Path of the file to be read */
    private readonly string $filePath;

    private readonly Options $options;

    /** @var XMLReader The XMLReader object that will help read sheet's XML data */
    private readonly XMLReader $xmlReader;

    /** @var ODS Used to unescape XML data */
    private readonly ODS $escaper;

    /** @var bool Whether there are still at least a sheet to be read */
    private bool $hasFoundSheet;

    /** @var int The index of the sheet being read (zero-based) */
    private int $currentSheetIndex;

    /** @var string The name of the sheet that was defined as active */
    private readonly ?string $activeSheetName;

    /** @var array<string, bool> Associative array [STYLE_NAME] => [IS_SHEET_VISIBLE] */
    private array $sheetsVisibility;

    public function __construct(
        string $filePath,
        Options $options,
        ODS $escaper,
        SettingsHelper $settingsHelper
    ) {
        $this->filePath = $filePath;
        $this->options = $options;
        $this->xmlReader = new XMLReader();
        $this->escaper = $escaper;
        $this->activeSheetName = $settingsHelper->getActiveSheetName($filePath);
    }

    /**
     * Rewind the Iterator to the first element.
     *
     * @see http://php.net/manual/en/iterator.rewind.php
     *
     * @throws IOException If unable to open the XML file containing sheets' data
     */
    public function rewind(): void
    {
        $this->xmlReader->close();

        if (false === $this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH)) {
            $contentXmlFilePath = $this->filePath.'#'.self::CONTENT_XML_FILE_PATH;

            throw new IOException("Could not open \"{$contentXmlFilePath}\".");
        }

        try {
            $this->sheetsVisibility = $this->readSheetsVisibility();
            $this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
        } catch (XMLProcessingException $exception) {
            throw new IOException("The content.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
        }

        $this->currentSheetIndex = 0;
    }

    /**
     * Checks if current position is valid.
     *
     * @see http://php.net/manual/en/iterator.valid.php
     */
    public function valid(): bool
    {
        $valid = $this->hasFoundSheet;
        if (!$valid) {
            $this->xmlReader->close();
        }

        return $valid;
    }

    /**
     * Move forward to next element.
     *
     * @see http://php.net/manual/en/iterator.next.php
     */
    public function next(): void
    {
        $this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);

        if ($this->hasFoundSheet) {
            ++$this->currentSheetIndex;
        }
    }

    /**
     * Return the current element.
     *
     * @see http://php.net/manual/en/iterator.current.php
     */
    public function current(): Sheet
    {
        $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
        \assert(null !== $escapedSheetName);
        $sheetName = $this->escaper->unescape($escapedSheetName);

        $isSheetActive = $this->isSheetActive($sheetName, $this->currentSheetIndex, $this->activeSheetName);

        $sheetStyleName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_STYLE_NAME);
        \assert(null !== $sheetStyleName);
        $isSheetVisible = $this->isSheetVisible($sheetStyleName);

        return new Sheet(
            new RowIterator(
                $this->options,
                new CellValueFormatter($this->options->SHOULD_FORMAT_DATES, new ODS()),
                new XMLProcessor($this->xmlReader)
            ),
            $this->currentSheetIndex,
            $sheetName,
            $isSheetActive,
            $isSheetVisible
        );
    }

    /**
     * Return the key of the current element.
     *
     * @see http://php.net/manual/en/iterator.key.php
     */
    public function key(): int
    {
        return $this->currentSheetIndex + 1;
    }

    /**
     * Extracts the visibility of the sheets.
     *
     * @return array<string, bool> Associative array [STYLE_NAME] => [IS_SHEET_VISIBLE]
     */
    private function readSheetsVisibility(): array
    {
        $sheetsVisibility = [];

        $this->xmlReader->readUntilNodeFound(self::XML_NODE_AUTOMATIC_STYLES);

        $automaticStylesNode = $this->xmlReader->expand();
        \assert($automaticStylesNode instanceof DOMElement);

        $tableStyleNodes = $automaticStylesNode->getElementsByTagNameNS(self::XML_STYLE_NAMESPACE, self::XML_NODE_STYLE_TABLE_PROPERTIES);

        foreach ($tableStyleNodes as $tableStyleNode) {
            $isSheetVisible = ('false' !== $tableStyleNode->getAttribute(self::XML_ATTRIBUTE_TABLE_DISPLAY));

            $parentStyleNode = $tableStyleNode->parentNode;
            \assert($parentStyleNode instanceof DOMElement);
            $styleName = $parentStyleNode->getAttribute(self::XML_ATTRIBUTE_STYLE_NAME);

            $sheetsVisibility[$styleName] = $isSheetVisible;
        }

        return $sheetsVisibility;
    }

    /**
     * Returns whether the current sheet was defined as the active one.
     *
     * @param string      $sheetName       Name of the current sheet
     * @param int         $sheetIndex      Index of the current sheet
     * @param null|string $activeSheetName Name of the sheet that was defined as active or NULL if none defined
     *
     * @return bool Whether the current sheet was defined as the active one
     */
    private function isSheetActive(string $sheetName, int $sheetIndex, ?string $activeSheetName): bool
    {
        // The given sheet is active if its name matches the defined active sheet's name
        // or if no information about the active sheet was found, it defaults to the first sheet.
        return
            (null === $activeSheetName && 0 === $sheetIndex)
            || ($activeSheetName === $sheetName);
    }

    /**
     * Returns whether the current sheet is visible.
     *
     * @param string $sheetStyleName Name of the sheet style
     *
     * @return bool Whether the current sheet is visible
     */
    private function isSheetVisible(string $sheetStyleName): bool
    {
        return $this->sheetsVisibility[$sheetStyleName] ??
            true;
    }
}
