<?php
/*
 * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
 *
 *  Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU Affero General Public License as published
 *  by the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU Affero General Public License for more details.
 *
 *  You should have received a copy of the GNU Affero General Public License
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);


namespace App\Services\EDA;

use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Part;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Trees\NodesListBuilder;
use App\Settings\MiscSettings\KiCadEDASettings;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class KiCadHelper
{

    /** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
    private readonly int $category_depth;

    /** @var bool Whether to resolve actual datasheet PDF URLs (true) or use Part-DB page links (false) */
    private readonly bool $datasheetAsPdf;

    public function __construct(
        private readonly NodesListBuilder $nodesListBuilder,
        private readonly TagAwareCacheInterface $kicadCache,
        private readonly EntityManagerInterface $em,
        private readonly ElementCacheTagGenerator $tagGenerator,
        private readonly UrlGeneratorInterface $urlGenerator,
        private readonly EntityURLGenerator $entityURLGenerator,
        private readonly TranslatorInterface $translator,
        private readonly KiCadEDASettings $kiCadEDASettings,
    ) {
        $this->category_depth = $kiCadEDASettings->categoryDepth;
        $this->datasheetAsPdf = $kiCadEDASettings->datasheetAsPdf ?? true;
    }

    /**
     * Returns an array of objects containing all categories in the database in the format required by KiCAD.
     * The categories are flattened and sorted by their full path.
     * Categories, which contain no parts, are filtered out.
     * The result is cached for performance and invalidated on category changes.
     * @return array
     */
    public function getCategories(): array
    {
        return $this->kicadCache->get('kicad_categories_' . $this->category_depth, function (ItemInterface $item) {
            //Invalidate the cache on category changes
            $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
            $item->tag($secure_class_name);

            //Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
            $secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
            $item->tag($secure_class_name);

            //If the category depth is smaller than 0, create only one dummy category
            if ($this->category_depth < 0) {
                return [
                    [
                        'id' => '0',
                        'name' => 'Part-DB',
                    ]
                ];
            }

            //Otherwise just get the categories and filter them

            $categories = $this->nodesListBuilder->typeToNodesList(Category::class);
            $repo = $this->em->getRepository(Category::class);
            $result = [];
            foreach ($categories as $category) {
                //Skip invisible categories
                if ($category->getEdaInfo()->getVisibility() === false) {
                    continue;
                }

                //Skip categories with a depth greater than the configured one
                if ($category->getLevel() > $this->category_depth) {
                    continue;
                }

                //Ensure that the category contains parts
                //For the last level, we need to use a recursive query, otherwise we can use a simple query
                /** @var Category $category */
                $parts_count = $category->getLevel() >= $this->category_depth ? $repo->getPartsCountRecursive($category) : $repo->getPartsCount($category);

                if ($parts_count < 1) {
                    continue;
                }

                //Check if the category should be visible
                if (!$this->shouldCategoryBeVisible($category)) {
                    continue;
                }

                //Format the category for KiCAD
                // Use the category comment as description if available, otherwise use the Part-DB URL
                $description = $category->getComment();
                if ($description === null || $description === '') {
                    $description = $this->entityURLGenerator->listPartsURL($category);
                }

                $result[] = [
                    'id' => (string)$category->getId(),
                    'name' => $category->getFullPath('/'),
                    'description' => $description,
                ];
            }

            return $result;
        });
    }

    /**
     * Returns an array of objects containing all parts for the given category in the format required by KiCAD.
     * The result is cached for performance and invalidated on category or part changes.
     * @param  Category|null  $category
     * @param  bool  $minimal  If true, only return id and name (faster for symbol chooser listing)
     * @return array
     */
    public function getCategoryParts(?Category $category, bool $minimal = false): array
    {
        $cacheKey = 'kicad_category_parts_'.($category?->getID() ?? 0) . '_' . $this->category_depth . ($minimal ? '_min' : '');
        return $this->kicadCache->get($cacheKey,
            function (ItemInterface $item) use ($category) {
                $item->tag([
                    $this->tagGenerator->getElementTypeCacheTag(Category::class),
                    $this->tagGenerator->getElementTypeCacheTag(Part::class),
                    //Visibility can change based on the footprint
                    $this->tagGenerator->getElementTypeCacheTag(Footprint::class)
                ]);

                if ($this->category_depth >= 0) {
                    //Ensure that the category is set
                    if ($category === null) {
                        throw new NotFoundHttpException('Category must be set, if category_depth is greater than 1!');
                    }

                    $category_repo = $this->em->getRepository(Category::class);
                    if ($category->getLevel() >= $this->category_depth) {
                        //Get all parts for the category and its children
                        $parts = $category_repo->getPartsRecursive($category);
                    } else {
                        //Get only direct parts for the category (without children), as the category is not collapsed
                        $parts = $category_repo->getParts($category);
                    }
                } else {
                    //Get all parts
                    $parts = $this->em->getRepository(Part::class)->findAll();
                }

                $result = [];
                foreach ($parts as $part) {
                    //If the part is invisible, then skip it
                    if (!$this->shouldPartBeVisible($part)) {
                        continue;
                    }

                    $result[] = [
                        'id' => (string)$part->getId(),
                        'name' => $part->getName(),
                        'description' => $part->getDescription(),
                    ];
                }

                return $result;
            });
    }

    public function getKiCADPart(Part $part): array
    {
        $result = [
            'id' => (string)$part->getId(),
            'name' => $part->getName(),
            "symbolIdStr" => $part->getEdaInfo()->getKicadSymbol() ?? $part->getCategory()?->getEdaInfo()->getKicadSymbol() ?? "",
            "exclude_from_bom" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBom() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBom() ?? false),
            "exclude_from_board" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromBoard() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromBoard() ?? false),
            "exclude_from_sim" => $this->boolToKicadBool($part->getEdaInfo()->getExcludeFromSim() ?? $part->getCategory()?->getEdaInfo()->getExcludeFromSim() ?? false),
            "description" => $part->getDescription(),
            "fields" => []
        ];

        $result["fields"]["footprint"] = $this->createField($part->getEdaInfo()->getKicadFootprint() ?? $part->getFootprint()?->getEdaInfo()->getKicadFootprint() ?? "");
        $result["fields"]["reference"] = $this->createField($part->getEdaInfo()->getReferencePrefix() ?? $part->getCategory()?->getEdaInfo()->getReferencePrefix() ?? 'U', true);
        $result["fields"]["value"] = $this->createField($part->getEdaInfo()->getValue() ?? $part->getName(), true);
        $result["fields"]["keywords"] = $this->createField($part->getTags());

        //Use the part info page as Part-DB link. It must be an absolute URL.
        $partUrl = $this->urlGenerator->generate(
            'part_info',
            ['id' => $part->getId()],
            UrlGeneratorInterface::ABSOLUTE_URL
        );

        //Try to find an actual datasheet attachment (configurable: PDF URL vs Part-DB page link)
        if ($this->datasheetAsPdf) {
            $datasheetUrl = $this->findDatasheetUrl($part);
            $result["fields"]["datasheet"] = $this->createField($datasheetUrl ?? $partUrl);
        } else {
            $result["fields"]["datasheet"] = $this->createField($partUrl);
        }
        $result["fields"]["Part-DB URL"] = $this->createField($partUrl);

        //Add basic fields
        $result["fields"]["description"] = $this->createField($part->getDescription());
        if ($part->getCategory() !== null) {
            $result["fields"]["Category"] = $this->createField($part->getCategory()->getFullPath('/'));
        }
        if ($part->getManufacturer() !== null) {
            $result["fields"]["Manufacturer"] = $this->createField($part->getManufacturer()->getName());
        }
        if ($part->getManufacturerProductNumber() !== "") {
            $result['fields']["MPN"] = $this->createField($part->getManufacturerProductNumber());
        }
        if ($part->getManufacturingStatus() !== null) {
            $result["fields"]["Manufacturing Status"] = $this->createField(
            //Always use the english translation
                $this->translator->trans($part->getManufacturingStatus()->toTranslationKey(), locale: 'en')
            );
        }
        if ($part->getFootprint() !== null) {
            $result["fields"]["Part-DB Footprint"] = $this->createField($part->getFootprint()->getName());
        }
        if ($part->getPartUnit() !== null) {
            $unit = $part->getPartUnit()->getName();
            if ($part->getPartUnit()->getUnit() !== "") {
                $unit .= ' ('.$part->getPartUnit()->getUnit().')';
            }
            $result["fields"]["Part-DB Unit"] = $this->createField($unit);
        }
        if ($part->getPartCustomState() !== null) {
            $customState = $part->getPartCustomState()->getName();
            $result["fields"]["Part-DB Custom state"] = $this->createField($customState);
        }
        if ($part->getMass()) {
            $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g');
        }
        $result["fields"]["Part-DB ID"] = $this->createField($part->getId());
        if ($part->getIpn() !== null && $part->getIpn() !== '' && $part->getIpn() !== '0') {
            $result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
        }

        //Add KiCost manufacturer fields (always present, independent of orderdetails)
        if ($part->getManufacturer() !== null) {
            $result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
        }
        if ($part->getManufacturerProductNumber() !== "") {
            $result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
        }

        // Add supplier information from orderdetails (include obsolete orderdetails)
        // If any orderdetail has eda_visibility explicitly set to true, only export those;
        // otherwise export all (backward compat when no flags are set)
        $allOrderdetails = $part->getOrderdetails(false);
        if ($allOrderdetails->count() > 0) {
            $hasExplicitEdaVisibility = false;
            foreach ($allOrderdetails as $od) {
                if ($od->isEdaVisibility() !== null) {
                    $hasExplicitEdaVisibility = true;
                    break;
                }
            }

            $supplierCounts = [];
            foreach ($allOrderdetails as $orderdetail) {
                if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
                    // When explicit flags exist, filter by resolved visibility
                    $resolvedVisibility = $orderdetail->isEdaVisibility() ?? $this->kiCadEDASettings->defaultOrderdetailsVisibility;
                    if ($hasExplicitEdaVisibility && !$resolvedVisibility) {
                        continue;
                    }

                    $supplierName = $orderdetail->getSupplier()->getName() . ' SPN';

                    if (!isset($supplierCounts[$supplierName])) {
                        $supplierCounts[$supplierName] = 0;
                    }
                    $supplierCounts[$supplierName]++;

                    // Create field name with sequential number if more than one from same supplier
                    $fieldName = $supplierCounts[$supplierName] > 1
                        ? $supplierName . ' ' . $supplierCounts[$supplierName]
                        : $supplierName;

                    $result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());

                    //Also add a KiCost-compatible field (supplier_name# = SPN)
                    $kicostFieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
                    $result["fields"][$kicostFieldName] = $this->createField($orderdetail->getSupplierPartNr());
                }
            }
        }

        //Add stock quantity and storage locations (only count non-expired lots with known quantity)
        $totalStock = 0;
        $locations = [];
        foreach ($part->getPartLots() as $lot) {
            $isAvailable = !$lot->isInstockUnknown() && $lot->isExpired() !== true;
            if ($isAvailable) {
                $totalStock += $lot->getAmount();
                if ($lot->getAmount() > 0 && $lot->getStorageLocation() !== null) {
                    $locations[] = $lot->getStorageLocation()->getName();
                }
            }
        }
        $result['fields']['Stock'] = $this->createField($totalStock);
        if ($locations !== []) {
            $result['fields']['Storage Location'] = $this->createField(implode(', ', array_unique($locations)));
        }

        //Add parameters marked for EDA export (explicit true, or system default when null)
        foreach ($part->getParameters() as $parameter) {
            $paramVisibility = $parameter->isEdaVisibility() ?? $this->kiCadEDASettings->defaultParameterVisibility;
            if ($paramVisibility && $parameter->getName() !== '') {
                $fieldName = $parameter->getName();
                //Don't overwrite hardcoded fields
                if (!isset($result['fields'][$fieldName])) {
                    $result['fields'][$fieldName] = $this->createField($parameter->getFormattedValue());
                }
            }
        }

        return $result;
    }

    /**
     * Determine if the given part should be visible for the EDA.
     * @param  Category  $category
     * @return bool
     */
    private function shouldCategoryBeVisible(Category $category): bool
    {
        $eda_info = $category->getEdaInfo();

        //If the category visibility is explicitly set, then use it
        if ($eda_info->getVisibility() !== null) {
            return $eda_info->getVisibility();
        }

        //try to check if the fields were set
        if ($eda_info->getKicadSymbol() !== null
            || $eda_info->getReferencePrefix() !== null) {
            return true;
        }

        //Check if there is any part in this category, which should be visible
        $category_repo = $this->em->getRepository(Category::class);
        if ($category->getLevel() >= $this->category_depth) {
            //Get all parts for the category and its children
            $parts = $category_repo->getPartsRecursive($category);
        } else {
            //Get only direct parts for the category (without children), as the category is not collapsed
            $parts = $category_repo->getParts($category);
        }

        foreach ($parts as $part) {
            if ($this->shouldPartBeVisible($part)) {
                return true;
            }
        }

        //Otherwise the category should be not visible
        return false;
    }

    /**
     * Determine if the given part should be visible for the EDA.
     * @param  Part  $part
     * @return bool
     */
    private function shouldPartBeVisible(Part $part): bool
    {
        $eda_info = $part->getEdaInfo();
        $category = $part->getCategory();

        //If the user set a visibility, then use it
        if ($eda_info->getVisibility() !== null) {
            return $eda_info->getVisibility();
        }

        //If the part has a category, then use the category visibility if possible
        if ($category && $category->getEdaInfo()->getVisibility() !== null) {
            return $category->getEdaInfo()->getVisibility();
        }

        //If both are null, then we try to determine the visibility based on if fields are set
        if ($eda_info->getKicadSymbol() !== null
            || $eda_info->getKicadFootprint() !== null
            || $eda_info->getReferencePrefix() !== null
            || $eda_info->getValue() !== null) {
            return true;
        }

        //Check also if the fields are set for the category (if it exists)
        if ($category && (
                $category->getEdaInfo()->getKicadSymbol() !== null
                || $category->getEdaInfo()->getReferencePrefix() !== null
            )) {
            return true;
        }
        //And on the footprint
        //Otherwise the part should be not visible
        return $part->getFootprint() && $part->getFootprint()->getEdaInfo()->getKicadFootprint() !== null;
    }

    /**
     * Converts a boolean value to the format required by KiCAD.
     * @param  bool  $value
     * @return string
     */
    private function boolToKicadBool(bool $value): string
    {
        return $value ? 'True' : 'False';
    }

    /**
     * Creates a field array for KiCAD
     * @param  string|int|float  $value
     * @param  bool  $visible
     * @return array
     */
    private function createField(string|int|float $value, bool $visible = false): array
    {
        return [
            'value' => (string)$value,
            'visible' => $this->boolToKicadBool($visible),
        ];
    }

    /**
     * Finds the URL to the actual datasheet file for the given part.
     * Searches attachments by type name, attachment name, and file extension.
     * @return string|null The datasheet URL, or null if no datasheet was found.
     */
    private function findDatasheetUrl(Part $part): ?string
    {
        $firstPdf = null;

        foreach ($part->getAttachments() as $attachment) {
            //Check if the attachment type name contains "datasheet"
            $typeName = $attachment->getAttachmentType()?->getName() ?? '';
            if (str_contains(mb_strtolower($typeName), 'datasheet')) {
                return $this->getAttachmentUrl($attachment);
            }

            //Check if the attachment name contains "datasheet"
            $name = mb_strtolower($attachment->getName());
            if (str_contains($name, 'datasheet') || str_contains($name, 'data sheet')) {
                return $this->getAttachmentUrl($attachment);
            }

            //Track first PDF as fallback (check internal extension or external URL path)
            if ($firstPdf === null) {
                $extension = $attachment->getExtension();
                if ($extension === null && $attachment->hasExternal()) {
                    $urlPath = parse_url($attachment->getExternalPath(), PHP_URL_PATH);
                    $extension = is_string($urlPath) ? strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) : null;
                }
                if ($extension === 'pdf') {
                    $firstPdf = $attachment;
                }
            }
        }

        //Use first PDF attachment as fallback
        if ($firstPdf !== null) {
            return $this->getAttachmentUrl($firstPdf);
        }

        return null;
    }

    /**
     * Returns an absolute URL for viewing the given attachment.
     * Prefers the external URL (direct link) over the internal view route.
     */
    private function getAttachmentUrl(Attachment $attachment): string
    {
        if ($attachment->hasExternal()) {
            return $attachment->getExternalPath();
        }

        return $this->urlGenerator->generate(
            'attachment_view',
            ['id' => $attachment->getId()],
            UrlGeneratorInterface::ABSOLUTE_URL
        );
    }
}
