[
  {
    "path": ".github/workflows/pr-checks.yml",
    "content": "name: PR Checks\n\non:\n  pull_request:\n    types: [opened, synchronize, reopened, ready_for_review]\n\npermissions:\n  contents: read\n\nenv:\n  PYTHON_VERSION: \"3.14\"\n\njobs:\n  check-linting:\n    if: github.event.pull_request.draft == false\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v5\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n          cache: pip\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements-dev.txt\n\n      - name: Ruff\n        run: ruff check main.py core utils tests\n\n  check-types:\n    if: github.event.pull_request.draft == false\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v5\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n          cache: pip\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements-dev.txt\n\n      - name: Black\n        run: black --check main.py core utils tests\n\n  check-tests:\n    if: github.event.pull_request.draft == false\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v5\n\n      - name: Set up Python\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ env.PYTHON_VERSION }}\n          cache: pip\n\n      - name: Install dependencies\n        run: |\n          python -m pip install --upgrade pip\n          pip install -r requirements-dev.txt\n\n      - name: Unit tests\n        run: python -m unittest discover -s tests\n"
  },
  {
    "path": ".gitignore",
    "content": "# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,python,node\n# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,visualstudiocode,python,node\n\n### Linux ###\n*~\n\n# temporary files which can be created if a process still has a handle open of a deleted file\n.fuse_hidden*\n\n# KDE directory preferences\n.directory\n\n# Linux trash folder which might appear on any partition or disk\n.Trash-*\n\n# .nfs files are created when an open file is removed but is still being accessed\n.nfs*\n\n### macOS ###\n# General\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Icon must end with two \\r\nIcon\n\n\n# Thumbnails\n._*\n\n# Files that might appear in the root of a volume\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.com.apple.timemachine.donotpresent\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n### macOS Patch ###\n# iCloud generated files\n*.icloud\n\n### Node ###\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports (https://nodejs.org/api/report.html)\nreport.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (https://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Snowpack dependency directory (https://snowpack.dev/)\nweb_modules/\n\n# Generated reports\nreports/\n\n# Generated reports\nconfig/aws.json\nconfig/azure.json\n\n# TypeScript cache\n*.tsbuildinfo\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional stylelint cache\n.stylelintcache\n\n# Microbundle cache\n.rpt2_cache/\n.rts2_cache_cjs/\n.rts2_cache_es/\n.rts2_cache_umd/\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variable files\n.env\n.env.development.local\n.env.test.local\n.env.production.local\n.env.local\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\nout\n\n# Nuxt.js build / generate output\n.nuxt\ndist\n\n# Gatsby files\n.cache/\n# Comment in the public line in if your project uses Gatsby and not Next.js\n# https://nextjs.org/blog/next-9-1#public-directory-support\n# public\n\n# vuepress build output\n.vuepress/dist\n\n# vuepress v2.x temp and cache directory\n.temp\n\n# Docusaurus cache and generated files\n.docusaurus\n\n# Serverless directories\n.serverless/\n\n# FuseBox cache\n.fusebox/\n\n# DynamoDB Local files\n.dynamodb/\n\n# TernJS port file\n.tern-port\n\n# Stores VSCode versions used for testing VSCode extensions\n.vscode-test\n\n# yarn v2\n.yarn/cache\n.yarn/unplugged\n.yarn/build-state.yml\n.yarn/install-state.gz\n.pnp.*\n\n### Node Patch ###\n# Serverless Webpack directories\n.webpack/\n\n# Optional stylelint cache\n\n# SvelteKit build / generate output\n.svelte-kit\n\n### Python ###\n# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n### VisualStudioCode ###\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n!.vscode/*.code-snippets\n\n# Local History for Visual Studio Code\n.history/\n\n# Built Visual Studio Code Extensions\n*.vsix\n\n### VisualStudioCode Patch ###\n# Ignore all local history of files\n.history\n.ionide\n\n### Windows ###\n# Windows thumbnail cache files\nThumbs.db\nThumbs.db:encryptable\nehthumbs.db\nehthumbs_vista.db\n\n# Dump file\n*.stackdump\n\n# Folder config file\n[Dd]esktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Windows Installer files\n*.cab\n*.msi\n*.msix\n*.msm\n*.msp\n\n# Windows shortcuts\n*.lnk\n\n# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,python,node\noutput/\nlogs/\n*.xlsx\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "![CloudExit](./docs/images/Main.png)\n\n# cloudexit – Cloud Exit Assessment (Open Source)\n\ncloudexit is an open-source tool that helps cloud engineers and technical teams assess **cloud exit readiness**.\n\nIt provides a structured, repeatable way to understand:\n- what cloud services are in use\n- where vendor lock-in risks exist\n- how difficult an exit scenario would be\n- what alternative technologies are available\n\ncloudexit runs **locally by default**, with no account required.\n\n---\n\n## How cloudexit fits into the EscapeCloud ecosystem\n\ncloudexit is the **Community / Open Source edition** of the EscapeCloud ecosystem.\n\n- **cloudexit (this repository)**  \n  Open-source, offline-first assessment engine\n\n- **exitcloud.io**  \n  Lightweight Cloud Exit Readiness Platform for individuals, SMEs, and MSPs\n\n- **escapecloud.io**  \n  Enterprise Cloud Exit Readiness Platform with advanced reporting and governance\n\ncloudexit can be used:\n- fully offline (Basic assessment)\n- or connected to a platform (exitcloud.io / escapecloud.io) for richer reports and scoring\n\n---\n\n## Documentation\n\n📘 **Full documentation:**  \n👉 https://cloudexit.escapecloud.io\n\nThe documentation covers:\n- getting started and prerequisites\n- running assessments\n- cloud providers and permissions\n- reports and scores\n- connected mode (exitcloud.io / escapecloud.io)\n- troubleshooting and contribution guidelines\n\n---\n\n## License\n\ncloudexit is licensed under the  \n**GNU Affero General Public License v3 (AGPL-3.0)**\n\nSee the [LICENSE](https://www.gnu.org/licenses/agpl-3.0.html) file for details.\n\n---\n\n## Contributing\n\nContributions are welcome.\n\nYou can contribute by:\n- reporting issues\n- improving documentation\n- submitting pull requests\n\nPlease see the documentation for contribution guidelines.\n"
  },
  {
    "path": "assets/css/style.css",
    "content": "/* ========================================================\n   Base: Variables\n   ======================================================== */\n:root {\r\n  /* Blue */\r\n  --blue-100: #dbe6fe;\r\n  --blue-800: #1e4baf;\r\n\r\n  /* Green */\n  --green-100: #dcfce7;\n  --green-600: #16a34a;\n  --green-700: #047854;\n\r\n  /* Neutral */\r\n  --neutral-50: #f9fbfb;\r\n  --neutral-100: #f3f6f6;\r\n  --neutral-200: #e5ebeb;\r\n  --neutral-300: #d1dbdb;\r\n  --neutral-400: #9cafae;\r\n  --neutral-600: #4b6361;\r\n  --neutral-800: #1f3735;\r\n  --neutral-900: #112726;\r\n\r\n  /* Primary */\r\n  --primary-600: #0d948b;\r\n  --primary-800: #115e59;\r\n  --primary-950: #042f2c;\r\n\r\n  /* Red */\n  --red-50: #fef2f2;\n  --red-100: #fee2e2;\n  --red-700: #b91c1c;\r\n  --red-800: #991b1b;\r\n\r\n  /* Yellow */\r\n  --yellow-100: #fee4c7;\r\n  --yellow-850: #92400e;\r\n  /* Color Palette */\r\n  --white: #fff;\r\n  /* Transition Defaults */\r\n  --transition-speed: 0.3s;\r\n\r\n  /* Font Sizes */\r\n  --text-heading-2: 24px;\r\n  --text-heading-3: 20px;\r\n  --text-body: 16px;\r\n  --text-label: 14px;\r\n  --text-label-small: 12px;\r\n  /* Radius */\r\n  --rounded-md: 12px;\r\n  --rounded-sm: 8px;\r\n  --rounded-xs: 4px;\r\n  /* Sidebar */\r\n}\r\n\r\n/* ========================================================\n   Base: Reset\n   ======================================================== */\n* {\r\n  margin: 0;\r\n  padding: 0;\r\n  -webkit-box-sizing: border-box;\r\n  box-sizing: border-box;\r\n}\r\n\r\nbody {\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n  line-height: 1.375;\n  font-weight: 400;\n  color: var(--neutral-900);\n  background: var(--neutral-100);\r\n  text-rendering: optimizeLegibility;\r\n  -webkit-font-smoothing: antialiased;\r\n  -moz-font-smoothing: antialiased;\r\n}\r\n\r\nimg {\r\n  max-width: 100%;\r\n  height: auto;\r\n}\r\n\r\nul {\r\n  list-style: none;\r\n  padding: 0;\r\n  margin: 0;\r\n}\r\n\r\n.divider-border {\r\n  height: 1px;\r\n  background: var(--neutral-200);\r\n}\r\n\r\n.container {\r\n}\r\n\r\n/* ========================================================\n   Base: Typography\n   ======================================================== */\na {\r\n  text-decoration: none;\r\n  -webkit-transition: all var(--transition-speed) ease;\r\n  -o-transition: all var(--transition-speed) ease;\r\n  transition: all var(--transition-speed) ease;\r\n}\r\n\r\na:hover {\r\n  text-decoration: none !important;\r\n}\r\n\r\n\r\nh2,\r\nh3 {\r\n  color: var(--neutral-900);\r\n  font-weight: 500;\r\n}\r\n\r\n\r\nh2,\r\nh3,\r\nli,\r\np {\r\n  margin: 0;\r\n}\r\n\r\n\r\nh2 {\r\n  font-size: var(--text-heading-2);\r\n  line-height: 1.33;\r\n}\r\n\r\n\r\nh3 {\r\n  font-size: var(--text-heading-3);\r\n  line-height: 1.4;\r\n}\r\n\r\n\r\nbody,\r\np {\r\n  font-size: var(--text-body);\r\n  line-height: 1.375;\r\n}\r\n\r\n.label,\r\nlabel {\r\n  font-size: var(--text-label);\r\n  line-height: 1.43;\r\n}\r\n\r\n.small,\r\nlabel.small {\r\n  font-size: var(--text-label-small);\r\n  line-height: 1.33;\r\n}\r\n\r\n.bg-white {\r\n  background: var(--white);\r\n}\r\n\r\na*:active,\r\na*:focus {\r\n  outline: 0;\r\n  border: 0;\r\n}\r\n\r\nmain {\r\n  padding-bottom: 32px;\r\n  padding-top: 88px;\r\n}\r\n\r\n/* Scrollbars */\n::-webkit-scrollbar {\n  width: 5px;\r\n  border-radius: var(--rounded-sm);\r\n}\r\n\r\n::-webkit-scrollbar-track {\n  background: var(--neutral-100);\r\n  border-radius: var(--rounded-sm);\r\n}\r\n\r\n::-webkit-scrollbar-thumb {\n  background: var(--primary-600);\r\n  border-radius: var(--rounded-sm);\r\n}\r\n\r\n::-webkit-scrollbar-thumb:hover {\n  background: var(--primary-800);\r\n  border-radius: var(--rounded-sm);\r\n}\r\n\r\n.dropdown-item span {\r\n  font-size: var(--text-label);\r\n  font-weight: 400;\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.dropdown-item {\r\n  padding-top: 8px;\r\n  padding-bottom: 8px;\r\n}\r\n\r\n.dropdown-menu {\r\n  padding: 0;\r\n}\r\n\r\n.dropdown-menu li a {\r\n  line-height: 1.5;\r\n}\r\n\r\n/* ========================================================\n   Layout: Shared Cards And Charts\n   ======================================================== */\n\n.chart-card-head {\n  padding: 16px 24px;\r\n}\r\n\r\n.chart-card-head h3 {\r\n  margin-bottom: 5px;\r\n  color: var(--neutral-900);\r\n}\r\n.chart-card-head h6 {\r\n  color: var(--neutral-600);\r\n  font-size: var(--text-label);\r\n  font-weight: 400;\r\n}\r\n.chart-card-head h6 span {\n  display: inline-block;\n  font-weight: 500;\n}\n\n.risk-header {\n  display: -webkit-box;\n  display: -ms-flexbox;\n  display: flex;\n  -webkit-box-pack: justify;\r\n  -ms-flex-pack: justify;\r\n  justify-content: space-between;\r\n  -webkit-box-align: center;\r\n  -ms-flex-align: center;\r\n  align-items: center;\n  /* padding: 16px 24px; */\n}\n\n.risk-dashboard {\n  display: flex;\n  flex-direction: column;\n  height: 100%;\n}\n\n.risk-count h2 {\n  color: var(--neutral-600);\n  font-size: var(--text-label);\n  font-weight: 400;\n}\n\r\n.risk-count .count {\n  font-size: var(--text-heading-3);\n  font-weight: 500;\n  color: var(--neutral-900);\n  margin: 0;\n}\n\n.chart-container {\n  display: -webkit-box;\n  display: -ms-flexbox;\n  display: flex;\n  -webkit-box-pack: center;\n  -ms-flex-pack: center;\r\n  justify-content: center;\r\n  -webkit-box-align: center;\r\n  -ms-flex-align: center;\n  align-items: center;\n  flex: 1;\n}\n\n.chart-box {\n    height: 350px;\n}\n\n.chart-empty-state {\n  flex: 1;\n  min-height: 220px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  text-align: center;\n}\n\n.chart-empty-state-inner {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 10px;\n}\n\n.chart-empty-state i {\n  font-size: 28px;\n  line-height: 1;\n  color: var(--neutral-900);\n}\n\n.chart-empty-state p {\n  margin: 0;\n  color: var(--neutral-700);\n}\n\n.alt-tech-empty-state {\n  min-height: 260px;\n}\n\n.scoring-empty-state {\n  min-height: 300px;\n}\n\n.scoring-card {\n  overflow: hidden;\n}\n\n.scoring-inner {\n  display: flex;\n  justify-content: center;\n  align-items: center;\n  height: 100%;\n  min-height: 300px;\n}\n\n.chart-wrapper {\n  max-width: 350px;\n  overflow: hidden;\n  position: relative;\n  border-radius: 12px;\n  mask-image: linear-gradient(to bottom, black 85%, transparent 100%);\n  -webkit-mask-image: linear-gradient(to bottom, black 85%, transparent 100%);\n}\n\n.scoring {\n  width: 100%;\n  max-width: 420px;\n  height: 320px !important;\n  margin: 0 auto;\n}\n\r\n.form-title {\n  font-size: var(--text-heading-3);\r\n  font-weight: 500;\r\n  line-height: 1.33;\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.shadow-s {\n  -webkit-box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);\r\n  box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);\r\n}\r\n\r\n.input {\r\n  border: 1px solid var(--neutral-300);\r\n}\r\n\r\n/* ========================================================\n   Components: Buttons\n   ======================================================== */\n\n.dropdown-toggle::after {\n  float: right;\r\n  margin-top: 8px;\r\n}\r\n\r\n/* ========================================================\n   Components: Summary, Scoring, Resources, Alt Tech Cards\n   ======================================================== */\n::-webkit-input-placeholder {\n  color: var(--neutral-600);\r\n  font-size: var(--text-body);\r\n  font-style: normal;\r\n  font-weight: 400;\r\n  line-height: 38px;\r\n}\r\n\r\n::-moz-placeholder {\r\n  color: var(--neutral-600);\r\n  font-size: var(--text-body);\r\n  font-style: normal;\r\n  font-weight: 400;\r\n  line-height: 38px;\r\n}\r\n\r\n.resource-card {\r\n  border: 1px solid var(--neutral-200);\r\n}\r\n\r\n.resource-card h3 {\r\n  font-weight: 500;\r\n  min-height: 44px;\r\n  font-size: var(--text-label);\r\n  font-size: 16px;\r\n  font-weight: 500;\r\n  font-stretch: normal;\r\n  line-height: 1.38;\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.resource-card h6 {\r\n  font-size: var(--text-label);\r\n  font-weight: 400;\r\n  margin: 0;\r\n}\r\n\r\n.resource-card {\r\n  height: 100%;\r\n}\r\n\r\n.resource-card p {\r\n  margin-top: 16px;\r\n  margin-bottom: 16px;\r\n}\r\n\r\n.resource-card img {\r\n    max-width: 100%;\r\n    margin: 0 0 16px;\r\n    height: 32px;\r\n}\r\n\r\n.gapy-3 {\r\n  gap: 16px 0;\r\n}\r\n\r\n.alttech-card {\r\n  border: 1px solid var(--neutral-200);\r\n  height: 100%;\r\n}\r\n\r\n.alttech-title {\r\n}\r\n\r\n.alttech-title h5 {\r\n  font-size: var(--text-body);\r\n  margin: 0;\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.alttech-title h6 {\r\n  font-size: var(--text-label-small);\r\n  font-weight: 400;\r\n}\r\n\r\n.green-700 {\r\n  color: var(--green-700);\r\n}\r\n\r\n.alttech-text p {\r\n  /*overflow: hidden;*/\r\n  /*display: -webkit-box;*/\r\n  font-size: 14px;\r\n  -webkit-line-clamp: 2;\r\n  -webkit-box-orient: vertical;\r\n}\r\n\r\n.sync-status {\r\n  gap: 28px 0;\r\n}\r\n\r\n.sync-status-text {\r\n}\r\n\r\n.sync-status-text h5 {\r\n  margin: 0;\r\n  font-size: var(--text-body);\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.sync-status-text h6 {\r\n  color: var(--neutral-600);\r\n  font-size: var(--text-label);\r\n  margin-bottom: 5px;\r\n  font-weight: 400;\r\n}\r\n\r\n.sync-status-icon span{\r\n    border-radius: 8px;\r\n    background:rgba(5, 81, 96, 0.10);\r\n    height: 32px;\r\n    width: 32px;\r\n    display: flex;\r\n    align-items: center;\r\n    justify-content: center;\r\n}\r\n\r\n.view-more button {\r\n  background: transparent;\r\n  border: 0;\r\n}\r\n\r\n/* ========================================================\n   Components: Risk Table\n   ======================================================== */\n.risk-title-cell {\n  position: relative;\r\n  padding-left: 40px !important;\r\n  cursor: pointer;\r\n}\r\n\r\n.chevron-icon {\r\n  position: absolute;\r\n  left: 16px;\r\n  top: 50%;\r\n  -webkit-transform: translateY(-50%);\r\n  -ms-transform: translateY(-50%);\r\n  transform: translateY(-50%);\r\n  font-size: var(--text-label-small);\r\n  color: var(--neutral-600);\r\n  -webkit-transition: -webkit-transform 0.2s ease;\r\n  transition: -webkit-transform 0.2s ease;\r\n  -o-transition: transform 0.2s ease;\r\n  transition: transform 0.2s ease;\r\n  transition:\r\n    transform 0.2s ease,\r\n    -webkit-transform 0.2s ease;\r\n}\r\n\r\n.risk-row.expanded .chevron-icon {\r\n  -webkit-transform: translateY(-50%) rotate(90deg);\r\n  -ms-transform: translateY(-50%) rotate(90deg);\r\n  transform: translateY(-50%) rotate(90deg);\r\n}\r\n\r\n.expandable-content {\r\n  display: none;\r\n  background-color: #f5f5f5;\r\n}\r\n\r\n.expandable-content.show {\r\n  display: table-row;\r\n}\r\n\r\n.expandable-content td {\r\n  padding-left: 20px;\r\n}\r\n\r\n.risk-table-container .table tr th {\r\n  font-weight: 500;\r\n  font-size: var(--text-label);\r\n}\r\n\r\n.risk-table-container .table th:first-child,\r\n.risk-table-container .table td:first-child {\r\n  width: auto !important;\r\n}\r\n\r\n.risk-table-container .table tr th:nth-child(3),\r\n.risk-table-container .table tr th:nth-child(4) {\r\n  text-align: center;\r\n}\r\n\r\n.impacted-count {\r\n  text-align: center;\r\n}\r\n\r\n.description {\r\n  background: var(--neutral-200);\r\n  padding-top: 12px;\r\n  padding-bottom: 12px;\r\n}\r\n\r\n.description-section {\r\n  margin-bottom: 10px;\r\n}\r\n\r\n.risk-title {\r\n  color: var(--neutral-900);\r\n}\r\n\r\n.section-label {\r\n  font-size: var(--text-label-small);\r\n  font-weight: 400;\r\n  color: var(--neutral-600);\r\n}\r\n\r\n.section-content {\r\n  font-size: var(--text-label);\r\n}\r\n\r\n.impacted-resources-content {\r\n  font-size: var(--text-label);\r\n}\r\n\r\n.severity-badge {\r\n  color: var(--colors-red-800);\r\n  font-size: var(--text-label);\r\n  font-weight: 500;\r\n  padding: 3px 12px;\r\n  border-radius: var(--rounded-md);\r\n}\r\n\r\n.severity-high {\r\n  background-color: var(--red-100);\r\n  color: var(--red-800);\r\n}\r\n\r\n.severity-medium {\r\n  background: var(--yellow-100);\r\n  color: var(--yellow-850);\r\n}\r\n\r\n.severity-low {\r\n  background: var(--blue-100);\r\n  color: var(--blue-800);\r\n}\r\n\r\n.btn {\n  border-radius: 6px;\r\n  line-height: 40px;\r\n  padding: 0 16px;\r\n  font-weight: 500;\r\n  -webkit-transition: 0.4s;\r\n  -o-transition: 0.4s;\r\n  transition: 0.4s;\r\n}\r\n\r\n.btn-primary {\r\n  background: var(--primary-800);\r\n  color: var(--white);\r\n}\r\n\r\n.btn-outline-primary {\r\n  color: var(--primary-800);\r\n}\r\n\r\n.btn-light {\r\n  background: var(--neutral-100);\r\n  border: 1px solid var(--neutral-100);\r\n  color: var(--neutral-600);\r\n}\r\n\r\n.btn-outline-primary,\r\n.btn-primary {\r\n  border: 1px solid var(--primary-800);\r\n}\r\n\r\n.btn-sm {\r\n  font-size: var(--text-label);\r\n  font-weight: 500;\r\n  line-height: 34px;\r\n}\r\n\r\n.btn-primary:hover {\r\n  background: var(--primary-950);\r\n  border-color: var(--primary-950);\r\n}\r\n\r\n.btn-primary:hover svg:not(.filter-icon) path {\r\n  fill: var(--white);\r\n}\r\n\r\n.btn-outline-primary:focus svg:not path,\r\n.btn-outline-primary:active svg:not path,\r\n.btn-outline-primary:hover svg:not path {\r\n  fill: var(--white);\r\n}\r\n\r\n.btn-outline-primary:hover svg.filter-icon path {\r\n  stroke: var(--white);\r\n}\r\n\r\n.btn-outline-primary:active,\r\n.btn-outline-primary:hover {\r\n  background: var(--primary-800) !important;\r\n  border-color: var(--primary-800) !important;\r\n  color: var(--white) !important;\r\n}\r\n.btn-outline-primary:active svg path,\r\n.btn-outline-primary:hover svg path {\r\n  fill: white;\r\n}\r\n\r\n.btn-light:hover {\r\n  background: var(--neutral-200);\r\n  border-color: var(--neutral-200);\r\n}\r\n\r\n.dropdown-toggle::after {\r\n  border: 0;\r\n  display: none;\r\n}\r\n\r\n.dropdown-toggle {\r\n  display: -webkit-box;\r\n  display: -ms-flexbox;\r\n  display: flex;\r\n  -webkit-box-align: center;\r\n  -ms-flex-align: center;\r\n  align-items: center;\r\n  -webkit-box-pack: center;\r\n  -ms-flex-pack: center;\r\n  justify-content: center;\r\n  gap: 8px;\r\n  padding-left: 16px;\r\n  padding-right: 16px;\r\n}\r\n\r\n.btn-sm.dropdown-toggle {\r\n  padding-left: 12px;\r\n  padding-right: 12px;\r\n}\r\n\r\n.btn:focus-visible,\r\n.btn.show:focus-visible,\r\n.btn:first-child:active:focus-visible,\r\n:not(.btn-check) + .btn:active:focus-visible {\r\n  -webkit-box-shadow: none !important;\r\n  box-shadow: none !important;\r\n}\r\n\r\n.btn-primary,\n.btn-outline-primary {\n  /* --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);\n    --bs-btn-focus-shadow-rgb: 13, 110, 253;\n    --bs-btn-disabled-bg: transparent;\n    --bs-btn-active-color: var(--neutral-white);\r\n    --bs-btn-hover-color: var(--neutral-white);\r\n    --bs-gradient: none; */\n}\n\n.btn-outline-primary {\n  --bs-btn-color: var(--primary-800);\n  --bs-btn-border-color: var(--primary-800);\n  --bs-btn-hover-color: var(--white);\n  --bs-btn-hover-bg: var(--primary-800);\n  --bs-btn-hover-border-color: var(--primary-800);\n  --bs-btn-active-color: var(--white);\n  --bs-btn-active-bg: var(--primary-800);\n  --bs-btn-active-border-color: var(--primary-800);\n  --bs-btn-disabled-color: var(--neutral-400);\n  --bs-btn-disabled-border-color: var(--neutral-300);\n  --bs-btn-focus-shadow-rgb: 17, 94, 89;\n}\n\n.btn-outline-primary.show,\n.btn-check:checked + .btn-outline-primary,\n.btn-check:active + .btn-outline-primary,\n.btn-outline-primary:focus,\n.btn-outline-primary:focus-visible {\n  background: var(--primary-800) !important;\n  border-color: var(--primary-800) !important;\n  color: var(--white) !important;\n}\n\n.btn-outline-primary.show svg.filter-icon path,\n.btn-check:checked + .btn-outline-primary svg.filter-icon path,\n.btn-check:active + .btn-outline-primary svg.filter-icon path,\n.btn-outline-primary:focus svg.filter-icon path,\n.btn-outline-primary:focus-visible svg.filter-icon path {\n  stroke: var(--white);\n}\n\r\n.btn-primary.disabled,\r\n.btn-primary:disabled {\r\n  border-color: var(--neutral-400);\r\n  background: var(--neutral-400);\r\n  color: var(--white);\r\n}\r\n\r\n.btn-outline-primary.disabled,\r\n.btn-outline-primary:disabled {\r\n  border-color: var(--neutral-300);\r\n  color: var(--neutral-400);\r\n}\r\n\r\n/* ========================================================\n   Components: Filter Toggles\n   ======================================================== */\n.toggle-switch {\n  position: relative;\r\n  width: 44px;\r\n  height: 24px;\r\n}\r\n\r\n.toggle-switch input[type=\"checkbox\"] {\r\n  opacity: 0;\r\n  width: 0;\r\n  height: 0;\r\n}\r\n\r\n.toggle-slider {\r\n  position: absolute;\r\n  cursor: pointer;\r\n  top: 0;\r\n  left: 0;\r\n  right: 0;\r\n  bottom: 0;\r\n  background-color: var(--neutral-200);\r\n  -webkit-transition: 0.3s ease;\r\n  -o-transition: 0.3s ease;\r\n  transition: 0.3s ease;\r\n  border-radius: var(--rounded-md);\r\n}\r\n\r\n.toggle-slider:before {\n  position: absolute;\n  content: \"\";\n  height: 20px;\n  width: 20px;\n  left: 2px;\n  bottom: 2px;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='10' fill='%23E5EBEB'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E\");\n}\n\n.toggle-slider:before {\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='10' fill='%23E5EBEB'/%3E%3Cpath d='M6.5 6.5L13.5 13.5M13.5 6.5L6.5 13.5' stroke='%234B6361' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E\");\n  background-repeat: no-repeat;\n  background-position: center;\n  background-size: 20px 20px;\n}\n\r\ninput:checked + .toggle-slider {\r\n  background-color: var(--primary-800);\r\n}\r\n\r\ninput:checked + .toggle-slider:before {\n  -webkit-transform: translateX(20px);\n  -ms-transform: translateX(20px);\n  transform: translateX(20px);\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none'%3E%3Ccircle cx='10' cy='10' r='10' fill='%230D948B'/%3E%3Cpath d='M5.5 10.5L8.5 13.5L14.5 7.5' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E\");\n  background-repeat: no-repeat;\n  background-position: center;\n  background-size: 20px 20px;\n}\n\r\n/* ========================================================\n   Components: Tables\n   ======================================================== */\ntable:not(\"#assessmentsTable\") {\n  table-layout: fixed;\r\n  width: 100%;\r\n}\r\n\r\n.table > :not(caption) > * > * {\r\n  color: var(--neutral-900);\r\n  font-size: var(--text-body);\r\n  padding-top: 16px;\r\n  padding-bottom: 16px;\r\n  border-color: var(--neutral-200);\r\n}\r\n\r\ntr:last-child td {\r\n  border-bottom: 0;\r\n}\r\n\r\n.risk-table-container {\r\n  overflow-x: auto;\r\n  -webkit-overflow-scrolling: touch;\r\n}\r\n\r\n.risk-table-container table {\r\n  min-width: 420px;\r\n}\r\n\r\n.risk-table-container::-webkit-scrollbar {\n  height: 20px;\n}\n\n.risk-table-container::-webkit-scrollbar-track {\n  background: var(--white);\r\n}\r\n\r\n.risk-table-container::-webkit-scrollbar-thumb {\n  background: var(--neutral-300);\r\n  border-radius: 20px;\r\n}\r\n\r\n.risk-table-container::-webkit-scrollbar-thumb:hover {\n  background: var(--neutral-600);\r\n}\r\n\r\nth:first-child,\r\ntd:first-child {\r\n  width: 50px !important;\r\n  font-weight: 500;\r\n}\r\n\r\ntd .number,\ntd button {\n  margin-left: auto;\n  margin-right: auto;\n}\n\n/* ========================================================\n   Layout: Main Content And Forms\n   ======================================================== */\n\n#main-content {\n    height: 100%;\n    width: 100%;\n    overflow: hidden;\n    padding: 20px 60px 20px 60px;\n    transition: all 0.3s linear;\n    -webkit-transition: all 0.3s linear;\n}\n\r\n.visit span {\n  font-size: 14px;\n  word-break: break-all;\n}\n\n.form-control,\n.form-select {\n  color: var(--neutral-600);\n  font-size: var(--text-body);\n  font-style: normal;\n  font-weight: 400;\n  border-radius: 16px;\n  border: 1px solid var(--neutral-300);\n  background: var(--white);\n  -webkit-transition: border-color 0.3s ease, box-shadow 0.3s ease;\n  -o-transition: border-color 0.3s ease, box-shadow 0.3s ease;\n  transition: border-color 0.3s ease, box-shadow 0.3s ease;\n  -webkit-box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n}\n\n.form-control:focus,\n.form-select:focus {\n  border-color: var(--primary-800);\n  box-shadow: 0 0 0 0.2rem rgba(17, 94, 89, 0.12);\n}\n\n.custom-search {\n  min-width: 240px;\n  height: 36px;\n  border-radius: 8px;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 3.5C5.96243 3.5 3.5 5.96243 3.5 9C3.5 12.0376 5.96243 14.5 9 14.5C10.519 14.5 11.893 13.8852 12.8891 12.8891C13.8852 11.893 14.5 10.519 14.5 9C14.5 5.96243 12.0376 3.5 9 3.5ZM2 9C2 5.13401 5.13401 2 9 2C12.866 2 16 5.13401 16 9C16 10.6625 15.4197 12.1906 14.4517 13.3911L17.7803 16.7197C18.0732 17.0126 18.0732 17.4874 17.7803 17.7803C17.4874 18.0732 17.0126 18.0732 16.7197 17.7803L13.3911 14.4517C12.1906 15.4197 10.6625 16 9 16C5.13401 16 2 12.866 2 9Z' fill='%236B807F'/%3E%3C/svg%3E\") !important;\n  background-repeat: no-repeat !important;\n  background-position: 16px center !important;\n  padding: 0 16px 0 48px !important;\n  width: 400px;\n}\n\r\n.btn-clear:hover {\r\n  text-decoration: underline;\r\n}\r\n\r\n.alt-tech-card {\n  background: #f5f5f5;\n  border-radius: 10px;\n}\n\n.alt-tech-card h3 {\n  font-size: 18px;\n  margin: 0;\n}\n\n/* ========================================================\n   Components: Alternative Technology Status And Hints\n   ======================================================== */\n\n.verified {\n    background: var(--green-600);\n    color: var(--white);\n}\r\n.green-100 {\r\n    background: var(--green-100);\r\n}\r\n.green-700 {\r\n    color: var(--green-700);\r\n}\r\n.tags span {\r\n    background: var(--neutral-100);\r\n    padding: 4px 8px;\r\n    border-radius: 6px;\r\n    font-size: var(--text-label);\r\n    color: var(--neutral-700);\r\n    font-weight: 500;\r\n}\r\n.verified span {\n    font-size: var(--text-label);\n}\n.red-700 {\n    color: var(--red-700);\n}\n.red-50 {\n    background: var(--red-50);\n}\n\n.info-hint-box {\n  position: relative;\n  display: inline-block;\n  margin-left: 6px;\r\n  cursor: pointer;\r\n}\r\n.info-hint-box i {\r\n  font-size: 16px;\r\n  color: var(--primary-600, #007bff);\r\n  transition: color 0.2s;\r\n}\r\n.info-hint-box:hover i {\r\n  color: var(--primary-800, #0056b3);\r\n}\r\n.hint-hoverbox {\r\n  display: none;\r\n  position: absolute;\r\n  top: 28px;\r\n  right: 0;\r\n  z-index: 1000;\r\n  background: #fff;\r\n  border: 1px solid var(--neutral-200, #e5e7eb);\r\n  border-radius: 8px;\r\n  padding: 12px 14px;\r\n  width: 280px;\r\n  box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);\r\n}\r\n.info-hint-box:hover .hint-hoverbox {\r\n  display: block;\r\n}\r\n.hint-hoverbox {\r\n  opacity: 0;\r\n  visibility: hidden;\r\n  transition: all 0.2s ease-in-out;\r\n}\r\n.info-hint-box:hover .hint-hoverbox {\r\n  opacity: 1;\r\n  visibility: visible;\r\n}\r\n.hint-hoverbox h5 {\r\n  font-size: 14px;\r\n  font-weight: 600;\r\n  margin-bottom: 6px;\r\n}\r\n.hint-hoverbox p {\n  font-size: 13px;\n  color: #555;\n  margin: 0;\n  line-height: 1.4;\n}\n\n/* ========================================================\n   Responsive\n   ======================================================== */\n\n@media (max-width: 991px) {\n\r\n  .alt-tech-card div {\r\n    width: 100%;\r\n    /* justify-content: flex-end; */\r\n  }\r\n\r\n  .alt-tech-card {\r\n    -ms-flex-wrap: wrap;\r\n    flex-wrap: wrap;\r\n    gap: 16px;\r\n  }\r\n\r\n  .chart-card-head {\r\n    padding-left: 16px;\r\n    padding-right: 16px;\r\n  }\r\n\r\n  #main-content {\r\n    margin-left: 0;\r\n  }\r\n}\r\n\r\n@media (max-width: 767px) {\r\n  main {\r\n    padding-top: 24px;\r\n  }\r\n\r\n  .btn {\r\n    padding-left: 12px;\r\n    padding-right: 12px;\r\n  }\r\n\r\n  main {\r\n    padding-top: 88px;\r\n  }\r\n\r\n}\r\n\r\n@media (max-width: 575px) {\r\n\r\n}\r\n"
  },
  {
    "path": "assets/template/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      sizes=\"16x16\"\n      href=\"assets/img/logo/favicon.png\"\n    />\n    <title>EscapeCloud Community Edition - Cloud Exit Assessment Report</title>\n    <link\n      rel=\"stylesheet\"\n      href=\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css\"\n    />\n    <link\n      href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\"\n      rel=\"stylesheet\"\n      integrity=\"sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN\"\n      crossorigin=\"anonymous\"\n    />\n    <link rel=\"stylesheet\" href=\"assets/css/style.css\" />\n    <script\n      src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"\n      integrity=\"sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL\"\n      crossorigin=\"anonymous\"\n    ></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script>\n    <script src=\"https://cdn.jsdelivr.net/npm/@visactor/vchart/build/index.min.js\"></script>\n  </head>\n  <body>\n    {% set provider_name = \"Microsoft Azure\" if cloud_service_provider == 1 else \"Amazon Web Services\" if cloud_service_provider == 2 else \"Unknown Provider\" %}\n    {% set strategy_name = \"Repatriation to On-Premises\" if exit_strategy == 1 else \"Migration to Alternate Cloud\" if exit_strategy == 3 else \"Unknown Strategy\" %}\n    {% set assessment_name = \"Basic\" if assessment_type == 1 else \"Standard\" if assessment_type == 2 else \"Unknown\" %}\n    {% set total_risks = high_risk_count + medium_risk_count + low_risk_count %}\n\n    <div class=\"bg-white px-4\">\n      <div\n        class=\"d-flex flex-column flex-md-row align-items-start justify-content-between align-self-md-center gap-3 py-4 px-4 project-title\"\n      >\n        <h2 class=\"d-flex align-items-center gap-3 px-4\">\n          <img src=\"assets/img/logo/logo.png\" width=\"30\" alt=\"EscapeCloud\" />\n          <span>{{ name }}</span>\n        </h2>\n        <div class=\"d-flex align-items-center gap-3 px-4 flex-wrap flex-sm-nowrap\">\n          <a href=\"report.pdf\" target=\"_blank\" class=\"btn btn-primary\"\n            >Executive Summary (PDF)</a\n          >\n        </div>\n      </div>\n      <div class=\"divider-border\"></div>\n    </div>\n\n    <main id=\"main-content\">\n      <div class=\"px-3 px-md-4\">\n        <div class=\"mt-1 row g-3 stats\">\n          <div class=\"col-xl-4 col-lg-6 col-md-6 chart-box\">\n            <div\n              class=\"d-flex align-items-center bg-white shadow-s p-3 p-lg-4 rounded-4 h-100\"\n            >\n              <div class=\"d-flex flex-wrap flex-column gap-25 sync-status\">\n                <div class=\"d-flex align-items-center gap-3 sync-status-card\">\n                  <div class=\"sync-status-icon\">\n                    <span>\n                      {% if cloud_service_provider == 1 %}\n                      <svg\n                        width=\"22\"\n                        height=\"20\"\n                        viewBox=\"0 0 22 20\"\n                        fill=\"none\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        aria-label=\"Microsoft Azure\"\n                        role=\"img\"\n                      >\n                        <path d=\"M7.86133 1.37256H13.4145L7.65764 18.4703C7.53972 18.8352 7.19666 19.0714 6.82145 19.0714H2.4904C1.99726 19.0714 1.61133 18.6742 1.61133 18.1912C1.61133 18.0946 1.62205 17.998 1.65421 17.9122L7.02514 1.97361C7.14306 1.61942 7.48611 1.37256 7.86133 1.37256Z\" fill=\"url(#azure-paint0)\"/>\n                        <path d=\"M15.9334 12.8354H7.13198C6.90686 12.8354 6.72461 13.0179 6.72461 13.2433C6.72461 13.3614 6.76749 13.4687 6.85325 13.5438L12.5136 18.8245C12.6744 18.9748 12.8996 19.0606 13.1247 19.0606H18.1097L15.9334 12.8354Z\" fill=\"#0078D4\"/>\n                        <path d=\"M7.86108 1.37256C7.47515 1.37256 7.13209 1.61942 7.02489 1.98434L1.66468 17.88C1.50388 18.3415 1.73973 18.846 2.2007 19.007C2.29719 19.0392 2.39367 19.0606 2.50088 19.0606H6.92841C7.26074 18.9962 7.53947 18.7601 7.65739 18.4381L8.72944 15.2826L12.5459 18.8674C12.7067 18.9962 12.9104 19.0714 13.1141 19.0714H18.0776L15.9014 12.8355H9.55491L13.4357 1.37256H7.86108Z\" fill=\"url(#azure-paint1)\"/>\n                        <path d=\"M14.9584 1.97361C14.8405 1.60869 14.4974 1.37256 14.1222 1.37256H7.93652C8.31174 1.37256 8.65479 1.61942 8.77272 1.97361L14.1329 17.9015C14.2937 18.363 14.0364 18.8674 13.5755 19.0177C13.4897 19.0499 13.3932 19.0606 13.2967 19.0606H19.4824C19.9755 19.0606 20.3615 18.6635 20.3615 18.1805C20.3615 18.0839 20.3508 17.9873 20.3186 17.9015L14.9584 1.97361Z\" fill=\"url(#azure-paint2)\"/>\n                        <defs>\n                          <linearGradient id=\"azure-paint0\" x1=\"9.88834\" y1=\"2.68414\" x2=\"4.11123\" y2=\"19.7311\" gradientUnits=\"userSpaceOnUse\">\n                            <stop stop-color=\"#114A8B\"/>\n                            <stop offset=\"1\" stop-color=\"#0765B6\"/>\n                          </linearGradient>\n                          <linearGradient id=\"azure-paint1\" x1=\"11.6889\" y1=\"10.6302\" x2=\"10.355\" y2=\"11.0807\" gradientUnits=\"userSpaceOnUse\">\n                            <stop stop-opacity=\"0.3\"/>\n                            <stop offset=\"0.071\" stop-opacity=\"0.2\"/>\n                            <stop offset=\"0.321\" stop-opacity=\"0.1\"/>\n                            <stop offset=\"0.623\" stop-opacity=\"0.05\"/>\n                            <stop offset=\"1\" stop-opacity=\"0\"/>\n                          </linearGradient>\n                          <linearGradient id=\"azure-paint2\" x1=\"10.9974\" y1=\"2.17127\" x2=\"17.3387\" y2=\"19.0457\" gradientUnits=\"userSpaceOnUse\">\n                            <stop stop-color=\"#3BC9F3\"/>\n                            <stop offset=\"1\" stop-color=\"#2892DF\"/>\n                          </linearGradient>\n                        </defs>\n                      </svg>\n                      {% elif cloud_service_provider == 2 %}\n                      <svg\n                        width=\"35\"\n                        height=\"15\"\n                        viewBox=\"0 0 35 20\"\n                        fill=\"none\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                        aria-label=\"Amazon Web Services\"\n                        role=\"img\"\n                      >\n                        <g clip-path=\"url(#aws-clip0)\">\n                          <path d=\"M10.0898 7.25005C10.0898 7.66672 10.1453 8.00005 10.2009 8.25005C10.2843 8.50005 10.3955 8.75005 10.5623 9.05561C10.6179 9.13894 10.6457 9.22228 10.6457 9.30561C10.6457 9.41672 10.5901 9.52783 10.4233 9.63894L9.7284 10.1112C9.61722 10.1667 9.53383 10.2223 9.45044 10.2223C9.33925 10.2223 9.22807 10.1667 9.11688 10.0556C8.95011 9.88894 8.83892 9.72228 8.72774 9.52783C8.61655 9.33339 8.50537 9.13894 8.39418 8.86117C7.5325 9.88894 6.44845 10.3889 5.11423 10.3889C4.16915 10.3889 3.44645 10.1112 2.89053 9.58339C2.3346 9.05561 2.05664 8.33339 2.05664 7.4445C2.05664 6.50005 2.3902 5.72228 3.05731 5.16672C3.72441 4.61117 4.64169 4.30561 5.78134 4.30561C6.17048 4.30561 6.55963 4.33339 6.94878 4.38894C7.36572 4.4445 7.78266 4.52783 8.2274 4.63894V3.83339C8.2274 3.00005 8.06063 2.38894 7.69928 2.05561C7.33792 1.72228 6.7542 1.55561 5.89252 1.55561C5.50337 1.55561 5.11423 1.61117 4.69728 1.6945C4.28034 1.80561 3.89119 1.91672 3.50204 2.08339C3.33527 2.13894 3.19629 2.1945 3.1129 2.22228C3.02951 2.22228 2.97392 2.25005 2.94612 2.25005C2.77934 2.25005 2.72375 2.13894 2.72375 1.91672V1.36117C2.72375 1.1945 2.75155 1.05561 2.80714 0.972277C2.86273 0.888943 2.97392 0.80561 3.1129 0.750054C3.50204 0.55561 3.97458 0.388943 4.50271 0.250054C5.05863 0.111165 5.61456 0.027832 6.22608 0.027832C7.56029 0.027832 8.50537 0.333388 9.14468 0.916721C9.7562 1.52783 10.062 2.41672 10.062 3.66672L10.0898 7.25005ZM5.58676 8.9445C5.94811 8.9445 6.33726 8.88894 6.72641 8.75005C7.11555 8.61117 7.47691 8.36117 7.78266 8.02783C7.94944 7.80561 8.08842 7.58339 8.17181 7.30561C8.2274 7.02783 8.283 6.72228 8.283 6.33339V5.86117C7.94944 5.77783 7.61589 5.72228 7.25454 5.66672C6.89319 5.61117 6.55963 5.61117 6.19828 5.61117C5.44778 5.61117 4.91965 5.75005 4.53051 6.05561C4.16915 6.36117 3.97458 6.77783 3.97458 7.33339C3.97458 7.86117 4.11356 8.25005 4.39152 8.50005C4.66949 8.83339 5.05863 8.9445 5.58676 8.9445ZM14.5094 10.1667C14.3148 10.1667 14.1758 10.1389 14.0924 10.0556C14.009 10.0001 13.9256 9.83339 13.87 9.61117L11.2572 1.00005C11.2016 0.777832 11.146 0.638943 11.146 0.55561C11.146 0.388943 11.2294 0.277832 11.424 0.277832H12.508C12.7304 0.277832 12.8694 0.30561 12.9528 0.388943C13.0362 0.444499 13.1195 0.611165 13.1751 0.833388L15.0375 8.1945L16.7608 0.833388C16.8164 0.611165 16.872 0.472276 16.9832 0.388943C17.0666 0.333388 17.2334 0.277832 17.428 0.277832H18.3174C18.5398 0.277832 18.6788 0.30561 18.7622 0.388943C18.8456 0.444499 18.929 0.611165 18.9845 0.833388L20.7357 8.27783L22.6536 0.833388C22.7092 0.611165 22.7926 0.472276 22.876 0.388943C22.9594 0.333388 23.0984 0.277832 23.3208 0.277832H24.3492C24.516 0.277832 24.6272 0.361165 24.6272 0.55561C24.6272 0.611165 24.6272 0.666721 24.5994 0.722277C24.5994 0.777832 24.5716 0.888943 24.516 1.00005L21.8198 9.61117C21.7642 9.83339 21.6808 9.97228 21.5974 10.0556C21.514 10.1112 21.375 10.1667 21.1804 10.1667H20.2354C20.013 10.1667 19.874 10.1389 19.7906 10.0556C19.7072 9.97228 19.6239 9.83339 19.5683 9.61117L17.8449 2.4445L16.1215 9.61117C16.0659 9.83339 16.0103 9.97228 15.8992 10.0556C15.8158 10.1389 15.649 10.1667 15.4544 10.1667H14.5094ZM28.8244 10.4445C28.2407 10.4445 27.657 10.3889 27.101 10.2501C26.5451 10.1112 26.1004 9.97228 25.8224 9.80561C25.6556 9.6945 25.5167 9.58339 25.4889 9.50006C25.4611 9.41672 25.4055 9.27783 25.4055 9.1945V8.63894C25.4055 8.41672 25.4889 8.30561 25.6556 8.30561C25.7112 8.30561 25.7946 8.30561 25.8502 8.33339C25.9058 8.36117 26.017 8.38894 26.1282 8.4445C26.5173 8.61117 26.9065 8.75005 27.3512 8.83339C27.796 8.91672 28.2407 8.97228 28.6854 8.97228C29.3803 8.97228 29.9363 8.86117 30.2976 8.61117C30.6868 8.36117 30.8813 8.00005 30.8813 7.55561C30.8813 7.25005 30.7701 7.00005 30.5756 6.77783C30.381 6.55561 29.9919 6.38894 29.4637 6.1945L27.8515 5.6945C27.0455 5.4445 26.4339 5.05561 26.0726 4.55561C25.7112 4.05561 25.5167 3.52783 25.5167 2.9445C25.5167 2.47228 25.6278 2.05561 25.8224 1.72228C26.017 1.38894 26.295 1.05561 26.6285 0.80561C26.9621 0.55561 27.3512 0.361165 27.796 0.222276C28.2407 0.0833876 28.7132 0.027832 29.1858 0.027832C29.4359 0.027832 29.6861 0.027832 29.9363 0.0833876C30.1864 0.111165 30.4366 0.166721 30.659 0.194499C30.8813 0.250054 31.1037 0.30561 31.2983 0.361165C31.4929 0.416721 31.6596 0.500054 31.7708 0.55561C31.9376 0.638943 32.0488 0.722277 32.1044 0.833388C32.16 0.916721 32.2156 1.05561 32.2156 1.1945V1.72228C32.2156 1.9445 32.1322 2.08339 31.9654 2.08339C31.882 2.08339 31.743 2.02783 31.5484 1.9445C30.9091 1.66672 30.2142 1.50005 29.4081 1.50005C28.7688 1.50005 28.2685 1.61117 27.9349 1.80561C27.6014 2.00005 27.4068 2.33339 27.4068 2.80561C27.4068 3.11117 27.518 3.38894 27.7404 3.58339C27.9627 3.80561 28.3797 4.00005 28.9634 4.1945L30.5478 4.6945C31.3539 4.9445 31.9376 5.30561 32.2711 5.75005C32.6047 6.1945 32.7715 6.72228 32.7715 7.30561C32.7715 7.77783 32.6603 8.22228 32.4935 8.58339C32.2989 8.97228 32.021 9.30561 31.6874 9.55561C31.3539 9.83339 30.9369 10.0278 30.4644 10.1667C29.9085 10.3889 29.3803 10.4445 28.8244 10.4445Z\" fill=\"#252F3E\"/>\n                          <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M30.937 15.8612C27.2679 18.5556 21.9311 20.0001 17.3725 20.0001C10.9515 20.0001 5.16993 17.639 0.805916 13.6945C0.472362 13.389 0.77812 12.9723 1.19506 13.1945C5.92042 15.9445 11.7298 17.5834 17.7616 17.5834C21.8199 17.5834 26.2951 16.7501 30.4089 15.0001C30.9926 14.7501 31.5208 15.4167 30.937 15.8612Z\" fill=\"#FF9900\"/>\n                          <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M32.438 14.1391C31.9655 13.528 29.3527 13.8613 28.1574 14.0002C27.7961 14.0558 27.7405 13.7224 28.074 13.5002C30.1587 12.028 33.6055 12.4447 33.9946 12.9447C34.3838 13.4447 33.8834 16.8891 31.9377 18.528C31.6319 18.778 31.354 18.6391 31.493 18.3058C31.9377 17.1947 32.9106 14.7224 32.438 14.1391Z\" fill=\"#FF9900\"/>\n                          <defs>\n                            <clipPath id=\"aws-clip0\">\n                              <rect width=\"33.4667\" height=\"20\" fill=\"white\" transform=\"translate(0.666992)\"/>\n                            </clipPath>\n                          </defs>\n                        </g>\n                      </svg>\n                      {% else %}\n                      <img src=\"assets/img/logo/logo.png\" alt=\"{{ provider_name }}\" />\n                      {% endif %}\n                    </span>\n                  </div>\n                  <div class=\"sync-status-text\">\n                    <h6>Cloud Service Provider</h6>\n                    <h5>{{ provider_name }}</h5>\n                  </div>\n                </div>\n                <div class=\"d-flex align-items-center gap-3 sync-status-card\">\n                  <div class=\"sync-status-icon\">\n                    <span><i class=\"bi bi-sign-turn-slight-right\"></i></span>\n                  </div>\n                  <div class=\"sync-status-text\">\n                    <h6>Exit Strategy</h6>\n                    <h5>{{ strategy_name }}</h5>\n                  </div>\n                </div>\n                <div class=\"d-flex align-items-center gap-3 sync-status-card\">\n                  <div class=\"sync-status-icon\">\n                    <span><i class=\"bi bi-box\"></i></span>\n                  </div>\n                  <div class=\"sync-status-text\">\n                    <h6>Assessment Type</h6>\n                    <h5>{{ assessment_name }}</h5>\n                  </div>\n                </div>\n                <div class=\"d-flex align-items-center gap-3 sync-status-card\">\n                  <div class=\"sync-status-icon\">\n                    <span><i class=\"bi bi-clock\"></i></span>\n                  </div>\n                  <div class=\"sync-status-text\">\n                    <h6>Timestamp</h6>\n                    <h5>{{ timestamp }}</h5>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"col-xl-4 col-lg-6 col-md-6 chart-box\">\n            <div class=\"bg-white shadow-s rounded-4 h-100\">\n              <div class=\"px-3 px-lg-4 py-3 risk-dashboard\">\n                <div class=\"risk-header\">\n                  <div class=\"risk-count\">\n                    <h2>Risks</h2>\n                    <div class=\"count counter\">{{ total_risks }}</div>\n                  </div>\n                </div>\n                {% if total_risks > 0 %}\n                <div class=\"my-3 chart-container\">\n                  <canvas id=\"risksChart\" width=\"300\" height=\"250\"></canvas>\n                </div>\n                {% else %}\n                <div class=\"chart-empty-state\">\n                  <div class=\"chart-empty-state-inner\">\n                    <i class=\"bi bi-pie-chart-fill\"></i>\n                    <p>No risk data available.</p>\n                  </div>\n                </div>\n                {% endif %}\n              </div>\n            </div>\n          </div>\n\n          <div class=\"col-xl-4 col-lg-6 col-md-6 chart-box\">\n            <div class=\"bg-white shadow-s rounded-4 h-100\">\n              <div class=\"px-3 px-lg-4 py-3 risk-dashboard\">\n                <div class=\"risk-header\">\n                  <div class=\"risk-count\">\n                    <h2>Costs (last 6 months)</h2>\n                    <div class=\"count\">\n                      {% if total_cost > 0 %}{{ currency_symbol }}{{ total_cost }}{% else\n                      %}-{% endif %}\n                    </div>\n                  </div>\n                </div>\n                {% if total_cost > 0 %}\n                <div class=\"mt-4 chart-container\">\n                  <canvas id=\"costsChart\" width=\"300\" height=\"250\"></canvas>\n                </div>\n                {% else %}\n                <div class=\"chart-empty-state\">\n                  <div class=\"chart-empty-state-inner\">\n                    <i class=\"bi bi-bar-chart-fill\"></i>\n                    <p>No cost data available.</p>\n                  </div>\n                </div>\n                {% endif %}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        {% if assessment_type == 2 %}\n        <div class=\"mt-1 row g-3\">\n          <div class=\"col-md-6\">\n            <div class=\"bg-white shadow-s rounded-4 h-100 scoring-card\">\n              <div class=\"scoring-title\">\n                <div\n                  class=\"d-flex align-items-center justify-content-between chart-card-head\"\n                >\n                  <div>\n                    <h3 class=\"m-0\">Exit Readiness Score</h3>\n                  </div>\n                  <div class=\"info-hint-box\">\n                    <i class=\"bi bi-info-circle\"></i>\n                    <div class=\"hint-hoverbox\">\n                      <h5>Exit Score</h5>\n                      <div class=\"hint-dt\">\n                        <p>\n                          This gauge chart represents the EscapeCloud Platform's\n                          exit score methodology, based on risk assessment\n                          results and the alternative technology landscape.\n                          <br /><br />It uses a benchmark developed by our\n                          experts to quantify the challenges and limitations of\n                          exiting the cloud:\n                          <br />- Complex (0 - 20)\n                          <br />- Challenging (20 - 40)\n                          <br />- Manageable (40 - 60)\n                          <br />- Smooth Transition (60 - 80)\n                          <br />- Seamless (80 - 100)\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n              <div class=\"divider-border\"></div>\n              {% if scoring_data %}\n              <div class=\"scoring-inner py-4\">\n                <div class=\"chart-wrapper\">\n                  <div\n                    class=\"scoring\"\n                    id=\"exitScoreChart\"\n                    style=\"width: 100%; height: 100%;\"\n                  ></div>\n                </div>\n              </div>\n              {% else %}\n              <div class=\"chart-empty-state scoring-empty-state\">\n                <div class=\"chart-empty-state-inner\">\n                  <i class=\"bi bi-speedometer2\"></i>\n                  <p>No exit score data available.</p>\n                </div>\n              </div>\n              {% endif %}\n            </div>\n          </div>\n\n          <div class=\"col-md-6\">\n            <div class=\"bg-white shadow-s rounded-4 h-100 scoring-card\">\n              <div class=\"scoring-title\">\n                <div\n                  class=\"d-flex align-items-center justify-content-between chart-card-head\"\n                >\n                  <div>\n                    <h3 class=\"m-0\">Vendor Lock-In Score</h3>\n                  </div>\n                  <div class=\"info-hintbx\">\n                    <i class=\"bi bi-info-circle\"></i>\n                    <div class=\"hint-hoverbox\">\n                      <h5>Vendor Lock-In Score</h5>\n                      <div class=\"hint-dt\">\n                        <p>\n                          The radar chart visualizes alternative technologies\n                          across three dimensions:\n                          <br />- Human (skills availability)\n                          <br />- Technology (maturity and vendor stability)\n                          <br />- Operational (ecosystem and support services)\n                          <br /><br /><b>Only where viable alternatives exist.</b>\n                        </p>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n              <div class=\"divider-border\"></div>\n              {% if scoring_data %}\n              <div class=\"py-4 scoring-inner\">\n                <div\n                  class=\"scoring\"\n                  id=\"vendorLockInScoreChart\"\n                  style=\"width: 100%; height: 100%;\"\n                ></div>\n              </div>\n              {% else %}\n              <div class=\"chart-empty-state scoring-empty-state\">\n                <div class=\"chart-empty-state-inner\">\n                  <i class=\"bi bi-stack\"></i>\n                  <p>No vendor lock-in score data available.</p>\n                </div>\n              </div>\n              {% endif %}\n            </div>\n          </div>\n        </div>\n        {% endif %}\n\n        <div class=\"mt-4 row g-0\">\n          <div class=\"col-12\">\n            <div class=\"bg-white shadow-s rounded-4\">\n              <div\n                class=\"d-flex align-items-center justify-content-between px-3 px-lg-4 py-3\"\n              >\n                <h3 class=\"form-title\">Risks</h3>\n                <div class=\"btn-group d-inline-block\">\n                  <button\n                    class=\"btn-outline-primary btn dropdown-toggle\"\n                    type=\"button\"\n                    id=\"dropdownMenuButtonSeverity\"\n                    data-bs-toggle=\"dropdown\"\n                    aria-expanded=\"false\"\n                  >\n                    All Severities <i class=\"ms-1 bi bi-chevron-down\"></i>\n                  </button>\n                  <ul\n                    class=\"dropdown-menu\"\n                    aria-labelledby=\"dropdownMenuButtonSeverity\"\n                  >\n                    <li>\n                      <a class=\"dropdown-item\" href=\"#\" data-severity=\"all\"\n                        >All Severities</a\n                      >\n                    </li>\n                    <li>\n                      <a class=\"dropdown-item\" href=\"#\" data-severity=\"high\">High</a>\n                    </li>\n                    <li>\n                      <a class=\"dropdown-item\" href=\"#\" data-severity=\"medium\"\n                        >Medium</a\n                      >\n                    </li>\n                    <li>\n                      <a class=\"dropdown-item\" href=\"#\" data-severity=\"low\">Low</a>\n                    </li>\n                  </ul>\n                </div>\n              </div>\n              <div class=\"divider-border\"></div>\n\n              <div class=\"risk-table-container p-3\">\n                <table class=\"table\">\n                  <thead>\n                    <tr>\n                      <th>#</th>\n                      <th>Risk</th>\n                      <th>Impacted Resources</th>\n                      <th>Severity</th>\n                    </tr>\n                  </thead>\n                  <tbody id=\"risk-table-body\">\n                    {% if risks %} {% for risk in risks %}\n                    <tr\n                      class=\"risk-row {% if loop.first %}expanded{% endif %}\"\n                      data-target=\"expandable-{{ loop.index }}\"\n                      data-severity=\"{{ risk.severity }}\"\n                    >\n                      <td class=\"risk-number\">{{ loop.index }}</td>\n                      <td class=\"risk-title-cell\">\n                        <svg\n                          class=\"chevron-icon\"\n                          width=\"6\"\n                          height=\"8\"\n                          viewBox=\"0 0 6 8\"\n                          fill=\"none\"\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                          aria-hidden=\"true\"\n                        >\n                          <path\n                            fill-rule=\"evenodd\"\n                            clip-rule=\"evenodd\"\n                            d=\"M0.76711 7.81586C0.537434 7.577 0.544882 7.19718 0.783745 6.9675L3.93395 4L0.783745 1.0325C0.544881 0.802823 0.537434 0.422997 0.76711 0.184134C0.996786 -0.0547285 1.37661 -0.0621767 1.61547 0.167499L5.21548 3.5675C5.33312 3.68062 5.39961 3.83679 5.39961 4C5.39961 4.16321 5.33312 4.31938 5.21548 4.4325L1.61548 7.8325C1.37661 8.06218 0.996786 8.05473 0.76711 7.81586Z\"\n                            fill=\"#112726\"\n                          />\n                        </svg>\n                        <span class=\"risk-title\">{{ risk.name }}</span>\n                      </td>\n                      <td class=\"impacted-count\">\n                        {% if risk.impacted_resources_count is none %}-{% else %}{{\n                        risk.impacted_resources_count }}{% endif %}\n                      </td>\n                      <td class=\"text-center\">\n                        <span\n                          class=\"severity-badge severity-{{ risk.severity }}\"\n                          >{{ risk.severity | capitalize }}</span\n                        >\n                      </td>\n                    </tr>\n                    <tr\n                      class=\"expandable-content {% if loop.first %}show{% endif %}\"\n                      id=\"expandable-{{ loop.index }}\"\n                      data-severity=\"{{ risk.severity }}\"\n                    >\n                      <td></td>\n                      <td colspan=\"3\">\n                        <div class=\"px-3 rounded-3 description\">\n                          <div class=\"description-section\">\n                            <div class=\"section-label\">Description</div>\n                            <div class=\"section-content\">{{ risk.description }}</div>\n                          </div>\n                          {% if risk.impacted_resources_count %}\n                          <div>\n                            <div class=\"section-label\">Impacted Resources</div>\n                            <div class=\"impacted-resources-content\">\n                              {{ risk.impacted_resources | join(\", \") }}\n                            </div>\n                          </div>\n                          {% endif %}\n                        </div>\n                      </td>\n                    </tr>\n                    {% endfor %} {% else %}\n                    <tr>\n                      <td colspan=\"4\" class=\"text-center py-4\">\n                        No risks were identified for this assessment.\n                      </td>\n                    </tr>\n                    {% endif %}\n                  </tbody>\n                </table>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"mt-4 row g-0\">\n          <div class=\"col-12\">\n            <div class=\"bg-white shadow-s rounded-4\">\n              <div\n                class=\"d-flex flex-wrap align-items-center justify-content-between gap-3 px-3 px-lg-4 py-3\"\n              >\n                <h3>Resource Inventory ({{ total_resources }})</h3>\n              </div>\n              <div class=\"divider-border\"></div>\n              <div class=\"resource-inner\">\n                <div class=\"p-3 p-lg-4\">\n                  <div class=\"row gapy-3\" id=\"resource-grid\">\n                    {% if resource_inventory %} {% for resource in resource_inventory %}\n                    <div class=\"col-xl-3 col-lg-6 col-md-6 resource-item\">\n                      <div class=\"p-3 p-lg-4 rounded-4 resource-card\">\n                        <img\n                          src=\"assets/{{ resource.icon | trim }}\"\n                          alt=\"{{ resource.name | trim }}\"\n                        />\n                        <h3>{{ resource.name | trim }}</h3>\n                        <h6>\n                          {{ resource.count }} Resource{% if resource.count != 1 %}s{%\n                          endif %} Available\n                        </h6>\n                      </div>\n                    </div>\n                    {% endfor %} {% else %}\n                    <div class=\"col-12 text-center py-4\">\n                      <p>\n                        No resources were discovered during the assessment.\n                      </p>\n                    </div>\n                    {% endif %}\n                  </div>\n                </div>\n                {% if resource_inventory|length > 8 %}\n                <div class=\"divider-border\"></div>\n                <div class=\"text-end view-more\">\n                  <button\n                    class=\"float-right mx-4 my-3 btn btn-light view-more-btn\"\n                    type=\"button\"\n                    id=\"resourceViewMore\"\n                  >\n                    View More\n                  </button>\n                </div>\n                {% endif %}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div class=\"mt-4 row g-0\">\n          <div class=\"col-12\">\n            <div class=\"bg-white shadow-s rounded-4\">\n              <div\n                class=\"d-flex flex-column flex-lg-row align-items-start align-items-lg-center justify-content-between gap-3 bg-white px-3 px-lg-4 py-3 alt-tech-card filter-wrapper\"\n              >\n                <h3>\n                  Alternative Technologies ({{ alternative_technologies | length }})\n                </h3>\n                {% if alternative_technologies %}\n                <div class=\"d-flex align-items-center gap-3 flex-wrap\">\n                  <div>\n                    <input\n                      type=\"search\"\n                      class=\"custom-search form-control form-control-sm\"\n                      placeholder=\"Search for technologies\"\n                      aria-controls=\"alt-tech-grid\"\n                      id=\"altTechSearch\"\n                    />\n                  </div>\n                  <div class=\"btn-group\">\n                    <button\n                      class=\"btn btn-outline-primary btn-sm d-flex align-items-center gap-2 flex-shrink-0 dropdown-toggle\"\n                      type=\"button\"\n                      id=\"dropdownMenuButtonAlternativeTechnology\"\n                      data-bs-toggle=\"dropdown\"\n                      data-bs-auto-close=\"outside\"\n                      aria-expanded=\"false\"\n                    >\n                      <span>Filters</span>\n                      <svg\n                        class=\"filter-icon\"\n                        width=\"16\"\n                        height=\"16\"\n                        viewBox=\"0 0 16 16\"\n                        fill=\"none\"\n                        xmlns=\"http://www.w3.org/2000/svg\"\n                      >\n                        <path\n                          d=\"M8.00004 2C9.8365 2 11.6368 2.1547 13.3887 2.45178C13.7439 2.51202 14 2.82237 14 3.18268V3.87868C14 4.2765 13.842 4.65803 13.5607 4.93934L9.93934 8.56066C9.65804 8.84196 9.5 9.2235 9.5 9.62132V11.5729C9.5 12.1411 9.179 12.6605 8.67082 12.9146L6.5 14V9.62132C6.5 9.2235 6.34196 8.84196 6.06066 8.56066L2.43934 4.93934C2.15804 4.65804 2 4.2765 2 3.87868V3.1827C2 2.82238 2.25605 2.51203 2.61129 2.45179C4.3632 2.1547 6.16355 2 8.00004 2Z\"\n                          stroke=\"#115E59\"\n                          stroke-width=\"1.5\"\n                          stroke-linecap=\"round\"\n                          stroke-linejoin=\"round\"\n                        />\n                      </svg>\n                    </button>\n\n                    <ul\n                      class=\"dropdown-menu p-3 shadow-lg border-0\"\n                      aria-labelledby=\"dropdownMenuButtonAlternativeTechnology\"\n                      style=\"width: 280px;\"\n                    >\n                      <div class=\"drop-header mb-2\">\n                        <h6 class=\"fw-semibold mb-1\">Filters</h6>\n                      </div>\n                      <div class=\"drop-body\">\n                        <div class=\"form-group mb-3\">\n                          <select class=\"form-select\" id=\"resourceTypeSelect\">\n                            <option value=\"all\" selected>Select Resource Type</option>\n                            {% for resource in resource_inventory %}\n                            <option value=\"{{ resource.resource_type | trim }}\">\n                              {{ resource.name | trim }}\n                            </option>\n                            {% endfor %}\n                          </select>\n                        </div>\n                        <div class=\"form-toggle-switch mb-3 d-flex justify-content-between align-items-center\">\n                          <h6 class=\"mb-0\">Open Source</h6>\n                          <div class=\"toggle-switch\">\n                            <input type=\"checkbox\" id=\"openSourceSwitch\" />\n                            <label for=\"openSourceSwitch\" class=\"toggle-slider\"></label>\n                          </div>\n                        </div>\n                        <div class=\"form-toggle-switch mb-3 d-flex justify-content-between align-items-center\">\n                          <h6 class=\"mb-0\">Enterprise Support</h6>\n                          <div class=\"toggle-switch\">\n                            <input type=\"checkbox\" id=\"enterpriseSupportSwitch\" />\n                            <label for=\"enterpriseSupportSwitch\" class=\"toggle-slider\"></label>\n                          </div>\n                        </div>\n                      </div>\n                      <div class=\"drop-footer d-flex justify-content-between mt-3\">\n                        <button type=\"button\" class=\"btn btn-outline-primary btn-sm\" id=\"clearFilters\">\n                          Clear\n                        </button>\n                        <button type=\"button\" class=\"btn btn-primary btn-sm\" id=\"applyFilters\">\n                          Apply\n                        </button>\n                      </div>\n                    </ul>\n                  </div>\n                </div>\n                {% endif %}\n              </div>\n              <div class=\"divider-border\"></div>\n\n              <div class=\"alttech-inner\">\n                <div class=\"p-3 p-lg-4\">\n                  <div class=\"row gapy-3\" id=\"alt-tech-grid\">\n                    {% if alternative_technologies %} {% for alt_tech in\n                    alternative_technologies %} {% set alt_resource = namespace(name=\"Resource Type \" ~ alt_tech.resource_type_id) %}\n                    {% for resource in resource_inventory %}\n                      {% if resource.resource_type|string == alt_tech.resource_type_id|string %}\n                        {% set alt_resource.name = resource.name %}\n                      {% endif %}\n                    {% endfor %}\n                    <div\n                      class=\"col-lg-6 alt-tech-item alt-tech-card-item\"\n                      data-resource-type=\"{{ alt_tech.resource_type_id }}\"\n                      data-open-source=\"{{ 'true' if alt_tech.open_source else 'false' }}\"\n                      data-enterprise-support=\"{{ 'true' if alt_tech.support_plan else 'false' }}\"\n                    >\n                      <div class=\"p-3 p-lg-4 rounded-4 alttech-card\">\n                        <div\n                          class=\"d-flex align-items-center justify-content-between alttech-title\"\n                        >\n                          <div>\n                            <h6 class=\"mb-1\">Category: {{ alt_resource.name }}</h6>\n                            <h3 class=\"mb-0\">{{ alt_tech.product_name }}</h3>\n                          </div>\n                        </div>\n                        <div class=\"my-3 alttech-text\">\n                          <p>{{ alt_tech.product_description }}</p>\n                        </div>\n                        <div class=\"d-flex gap-3 flex-wrap mb-3\">\n                          <div\n                            class=\"verified d-inline-flex align-items-center px-2 rounded-1 gap-2 py-1 {% if alt_tech.open_source %}green-100{% else %}red-50{% endif %}\"\n                          >\n                            {% if alt_tech.open_source %}\n                            <svg\n                              width=\"14\"\n                              height=\"12\"\n                              viewBox=\"0 0 14 12\"\n                              fill=\"none\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                              aria-hidden=\"true\"\n                            >\n                              <path\n                                fill-rule=\"evenodd\"\n                                clip-rule=\"evenodd\"\n                                d=\"M13.7045 0.153466C14.034 0.404497 14.0976 0.875094 13.8466 1.20457L5.84657 11.7046C5.71541 11.8767 5.51627 11.9838 5.30033 11.9983C5.08439 12.0129 4.87271 11.9334 4.71967 11.7804L0.21967 7.28037C-0.0732233 6.98748 -0.0732233 6.5126 0.21967 6.21971C0.512563 5.92682 0.987437 5.92682 1.28033 6.21971L5.17351 10.1129L12.6534 0.295507C12.9045 -0.0339712 13.3751 -0.0975653 13.7045 0.153466Z\"\n                                fill=\"#198754\"\n                              />\n                            </svg>\n                            {% else %}\n                            <svg\n                              width=\"10\"\n                              height=\"10\"\n                              viewBox=\"0 0 10 10\"\n                              fill=\"none\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                              aria-hidden=\"true\"\n                            >\n                              <path\n                                d=\"M1.28033 0.21967C0.987437 -0.0732233 0.512563 -0.0732233 0.21967 0.21967C-0.0732233 0.512563 -0.0732233 0.987437 0.21967 1.28033L3.93934 5L0.21967 8.71967C-0.0732233 9.01256 -0.0732233 9.48744 0.21967 9.78033C0.512563 10.0732 0.987437 10.0732 1.28033 9.78033L5 6.06066L8.71967 9.78033C9.01256 10.0732 9.48744 10.0732 9.78033 9.78033C10.0732 9.48744 10.0732 9.01256 9.78033 8.71967L6.06066 5L9.78033 1.28033C10.0732 0.987437 10.0732 0.512563 9.78033 0.21967C9.48744 -0.0732233 9.01256 -0.0732233 8.71967 0.21967L5 3.93934L1.28033 0.21967Z\"\n                                fill=\"#D72323\"\n                              />\n                            </svg>\n                            {% endif %}\n                            <span\n                              class=\"{% if alt_tech.open_source %}green-700{% else %}red-700{% endif %}\"\n                              >Open Source</span\n                            >\n                          </div>\n                          <div\n                            class=\"verified d-inline-flex align-items-center px-2 rounded-1 gap-2 py-1 {% if alt_tech.support_plan %}green-100{% else %}red-50{% endif %}\"\n                          >\n                            {% if alt_tech.support_plan %}\n                            <svg\n                              width=\"14\"\n                              height=\"12\"\n                              viewBox=\"0 0 14 12\"\n                              fill=\"none\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                              aria-hidden=\"true\"\n                            >\n                              <path\n                                fill-rule=\"evenodd\"\n                                clip-rule=\"evenodd\"\n                                d=\"M13.7045 0.153466C14.034 0.404497 14.0976 0.875094 13.8466 1.20457L5.84657 11.7046C5.71541 11.8767 5.51627 11.9838 5.30033 11.9983C5.08439 12.0129 4.87271 11.9334 4.71967 11.7804L0.21967 7.28037C-0.0732233 6.98748 -0.0732233 6.5126 0.21967 6.21971C0.512563 5.92682 0.987437 5.92682 1.28033 6.21971L5.17351 10.1129L12.6534 0.295507C12.9045 -0.0339712 13.3751 -0.0975653 13.7045 0.153466Z\"\n                                fill=\"#198754\"\n                              />\n                            </svg>\n                            {% else %}\n                            <svg\n                              width=\"10\"\n                              height=\"10\"\n                              viewBox=\"0 0 10 10\"\n                              fill=\"none\"\n                              xmlns=\"http://www.w3.org/2000/svg\"\n                              aria-hidden=\"true\"\n                            >\n                              <path\n                                d=\"M1.28033 0.21967C0.987437 -0.0732233 0.512563 -0.0732233 0.21967 0.21967C-0.0732233 0.512563 -0.0732233 0.987437 0.21967 1.28033L3.93934 5L0.21967 8.71967C-0.0732233 9.01256 -0.0732233 9.48744 0.21967 9.78033C0.512563 10.0732 0.987437 10.0732 1.28033 9.78033L5 6.06066L8.71967 9.78033C9.01256 10.0732 9.48744 10.0732 9.78033 9.78033C10.0732 9.48744 10.0732 9.01256 9.78033 8.71967L6.06066 5L9.78033 1.28033C10.0732 0.987437 10.0732 0.512563 9.78033 0.21967C9.48744 -0.0732233 9.01256 -0.0732233 8.71967 0.21967L5 3.93934L1.28033 0.21967Z\"\n                                fill=\"#D72323\"\n                              />\n                            </svg>\n                            {% endif %}\n                            <span\n                              class=\"{% if alt_tech.support_plan %}green-700{% else %}red-700{% endif %}\"\n                              >Enterprise Support</span\n                            >\n                          </div>\n                        </div>\n                        <div class=\"divider-border my-3\"></div>\n                        <div class=\"visit text-end pt-2\">\n                          {% if alt_tech.product_url %}\n                          <a\n                            href=\"{{ alt_tech.product_url }}{% if '?' in alt_tech.product_url %}&{% else %}?{% endif %}utm_source=escapecloud&utm_medium=referral\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            class=\"d-inline-flex align-items-center text-underline gap-2\"\n                          >\n                            <i class=\"bi bi-box-arrow-up-right\"></i>\n                            <span>Visit {{ alt_tech.product_name }}</span>\n                          </a>\n                          {% endif %}\n                        </div>\n                      </div>\n                    </div>\n                    {% endfor %} {% else %}\n                    <div class=\"col-12\">\n                      <div class=\"chart-empty-state alt-tech-empty-state\">\n                        <div class=\"chart-empty-state-inner\">\n                          <i class=\"bi bi-search\"></i>\n                          <p>\n                            No alternative technologies are available for this\n                            assessment.\n                          </p>\n                        </div>\n                      </div>\n                    </div>\n                    {% endif %}\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <script>\n        document.addEventListener(\"DOMContentLoaded\", function () {\n          document.querySelectorAll(\".risk-row\").forEach((row) => {\n            row.addEventListener(\"click\", function () {\n              const details = document.getElementById(this.dataset.target);\n              if (!details) {\n                return;\n              }\n\n              const isOpen = details.classList.contains(\"show\");\n              details.classList.toggle(\"show\", !isOpen);\n              this.classList.toggle(\"expanded\", !isOpen);\n            });\n          });\n\n          const severityButton = document.getElementById(\"dropdownMenuButtonSeverity\");\n          const severityItems = document.querySelectorAll(\n            \"#dropdownMenuButtonSeverity + .dropdown-menu .dropdown-item\"\n          );\n\n          severityItems.forEach((item) => {\n            item.addEventListener(\"click\", function (event) {\n              event.preventDefault();\n              const severity = this.dataset.severity;\n\n              if (severityButton) {\n                severityButton.innerHTML =\n                  this.textContent.trim() +\n                  ' <i class=\"ms-1 bi bi-chevron-down\"></i>';\n              }\n\n              document.querySelectorAll(\"#risk-table-body .risk-row\").forEach((row) => {\n                const matches = severity === \"all\" || row.dataset.severity === severity;\n                row.style.display = matches ? \"\" : \"none\";\n\n                const details = document.getElementById(row.dataset.target);\n                if (details) {\n                  details.style.display =\n                    matches && details.classList.contains(\"show\")\n                      ? \"table-row\"\n                      : matches\n                        ? \"\"\n                        : \"none\";\n                }\n              });\n            });\n          });\n\n          const resourceItems = Array.from(\n            document.querySelectorAll(\"#resource-grid .resource-item\")\n          );\n          const resourceViewMoreButton = document.getElementById(\"resourceViewMore\");\n          let resourceVisibleLimit = 8;\n\n          function syncResourceVisibility() {\n            resourceItems.forEach((item, index) => {\n              item.style.display = index < resourceVisibleLimit ? \"\" : \"none\";\n            });\n\n            if (resourceViewMoreButton) {\n              resourceViewMoreButton.style.display =\n                resourceItems.length > resourceVisibleLimit ? \"\" : \"none\";\n            }\n          }\n\n          if (resourceItems.length) {\n            syncResourceVisibility();\n          }\n\n          if (resourceViewMoreButton) {\n            resourceViewMoreButton.addEventListener(\"click\", function () {\n              resourceVisibleLimit += 8;\n              syncResourceVisibility();\n            });\n          }\n\n          const applyFiltersBtn = document.getElementById(\"applyFilters\");\n          const clearFiltersBtn = document.getElementById(\"clearFilters\");\n          const resourceTypeSelect = document.getElementById(\"resourceTypeSelect\");\n          const openSourceSwitch = document.getElementById(\"openSourceSwitch\");\n          const enterpriseSupportSwitch = document.getElementById(\n            \"enterpriseSupportSwitch\"\n          );\n          const technologyBoxes = document.querySelectorAll(\".alt-tech-card-item\");\n          const searchInput = document.getElementById(\"altTechSearch\");\n          const gridContainer = document.getElementById(\"alt-tech-grid\");\n\n          if (\n            applyFiltersBtn &&\n            clearFiltersBtn &&\n            resourceTypeSelect &&\n            openSourceSwitch &&\n            enterpriseSupportSwitch &&\n            searchInput &&\n            gridContainer\n          ) {\n            let noResultsMessage = document.getElementById(\"noResultsMessage\");\n            if (!noResultsMessage) {\n              noResultsMessage = document.createElement(\"div\");\n              noResultsMessage.id = \"noResultsMessage\";\n              noResultsMessage.className = \"col-12 text-center text-muted py-4\";\n              noResultsMessage.style.display = \"none\";\n              noResultsMessage.innerHTML =\n                '<i class=\"bi bi-search fs-1\"></i><p class=\"mb-0\">No alternative technologies found for the selected criteria.</p>';\n              gridContainer.appendChild(noResultsMessage);\n            }\n\n            technologyBoxes.forEach((box) => {\n              box.dataset.filtered = \"true\";\n            });\n\n            function syncAltTechVisibility() {\n              let filteredCount = 0;\n\n              technologyBoxes.forEach((box) => {\n                const matchesFilter = box.dataset.filtered !== \"false\";\n                if (matchesFilter) {\n                  filteredCount += 1;\n                  box.style.display = \"\";\n                } else {\n                  box.style.display = \"none\";\n                }\n              });\n\n              noResultsMessage.style.display = filteredCount === 0 ? \"\" : \"none\";\n            }\n\n            function applyAlternativeFilters() {\n              const selectedResourceType = resourceTypeSelect.value;\n              const isOpenSource = openSourceSwitch.checked;\n              const hasEnterpriseSupport = enterpriseSupportSwitch.checked;\n              const searchQuery = searchInput.value.trim().toLowerCase();\n\n              technologyBoxes.forEach((box) => {\n                const matchesResourceType =\n                  selectedResourceType === \"all\" ||\n                  box.dataset.resourceType === selectedResourceType;\n                const matchesOpenSource =\n                  !isOpenSource || box.dataset.openSource === \"true\";\n                const matchesEnterpriseSupport =\n                  !hasEnterpriseSupport || box.dataset.enterpriseSupport === \"true\";\n                const matchesSearch =\n                  !searchQuery || box.textContent.toLowerCase().includes(searchQuery);\n\n                box.dataset.filtered =\n                  matchesResourceType &&\n                  matchesOpenSource &&\n                  matchesEnterpriseSupport &&\n                  matchesSearch\n                    ? \"true\"\n                    : \"false\";\n              });\n\n              syncAltTechVisibility();\n            }\n\n            applyFiltersBtn.addEventListener(\"click\", function () {\n              applyAlternativeFilters();\n            });\n\n            clearFiltersBtn.addEventListener(\"click\", function () {\n              resourceTypeSelect.value = \"all\";\n              openSourceSwitch.checked = false;\n              enterpriseSupportSwitch.checked = false;\n              searchInput.value = \"\";\n              technologyBoxes.forEach((box) => {\n                box.dataset.filtered = \"true\";\n                box.style.display = \"\";\n              });\n              syncAltTechVisibility();\n            });\n\n            searchInput.addEventListener(\"keyup\", function () {\n              applyAlternativeFilters();\n            });\n\n            syncAltTechVisibility();\n          }\n        });\n      </script>\n\n      {% if total_risks > 0 %}\n      <script>\n        const risksCanvas = document.getElementById(\"risksChart\");\n        if (risksCanvas) {\n          const risksChart = new Chart(risksCanvas.getContext(\"2d\"), {\n            type: \"doughnut\",\n            data: {\n              labels: [\"High\", \"Medium\", \"Low\"],\n              datasets: [\n                {\n                  label: \"Risk(s)\",\n                  data: [{{ high_risk_count }}, {{ medium_risk_count }}, {{ low_risk_count }}],\n                  backgroundColor: [\n                    \"rgba(153, 27, 27, 1)\",\n                    \"rgba(255, 174, 31, 1)\",\n                    \"rgba(83, 155, 255, 1)\",\n                  ],\n                  borderColor: [\n                    \"rgba(153, 27, 27, 1)\",\n                    \"rgba(255, 174, 31, 1)\",\n                    \"rgba(83, 155, 255, 1)\",\n                  ],\n                  borderWidth: 1,\n                },\n              ],\n            },\n            options: {\n              responsive: true,\n              maintainAspectRatio: false,\n              cutout: \"60%\",\n              plugins: {\n                legend: {\n                  position: \"bottom\",\n                  labels: {\n                    boxWidth: 20,\n                    padding: 15,\n                  },\n                },\n              },\n            },\n          });\n        }\n      </script>\n      {% endif %}\n\n      {% if total_cost > 0 %}\n      <script>\n        const costsCanvas = document.getElementById(\"costsChart\");\n        if (costsCanvas) {\n          const months = JSON.parse('{{ months_json|safe }}');\n          const costs = JSON.parse('{{ costs_json|safe }}');\n          const currencySymbol = \"{{ currency_symbol|safe }}\";\n\n          const costsChart = new Chart(costsCanvas.getContext(\"2d\"), {\n            type: \"bar\",\n            data: {\n              labels: months,\n              datasets: [\n                {\n                  label: `Costs (${currencySymbol})`,\n                  data: costs,\n                  backgroundColor: \"rgba(17, 94, 89, 0.25)\",\n                  borderColor: \"rgba(17, 94, 89, 1)\",\n                  borderWidth: 1,\n                },\n              ],\n            },\n            options: {\n              scales: {\n                y: {\n                  beginAtZero: true,\n                  title: {\n                    display: true,\n                    text: `Amount (${currencySymbol})`,\n                  },\n                },\n                x: {\n                  title: {\n                    display: false,\n                    text: \"Month\",\n                  },\n                },\n              },\n              responsive: true,\n              plugins: {\n                legend: {\n                  display: false,\n                },\n              },\n              maintainAspectRatio: false,\n            },\n          });\n        }\n      </script>\n      {% endif %}\n\n      {% if scoring_data %}\n      <script>\n        const radarSpec = {\n          type: \"radar\",\n          height: 350,\n          data: [\n            {\n              id: \"radarData\",\n              values: [\n                { key: \"Human\", value: {{ human | default(0) }} },\n                { key: \"Technology\", value: {{ technology | default(0) }} },\n                { key: \"Operational\", value: {{ operational | default(0) }} },\n              ],\n            },\n          ],\n          categoryField: \"key\",\n          valueField: \"value\",\n          point: {\n            visible: false,\n          },\n          area: {\n            visible: true,\n            style: {\n              fill: \"rgba(17, 94, 89, 0.25)\",\n            },\n            state: {\n              hover: {\n                fillOpacity: 0.5,\n              },\n            },\n          },\n          line: {\n            style: {\n              lineWidth: 4,\n              stroke: \"rgba(17, 94, 89, 1)\",\n            },\n          },\n          axes: [\n            {\n              orient: \"radius\",\n              zIndex: 100,\n              min: 0,\n              max: 5,\n              domainLine: {\n                visible: false,\n              },\n              label: {\n                visible: true,\n                space: 0,\n                style: {\n                  textAlign: \"center\",\n                  stroke: \"#fff\",\n                  lineWidth: 4,\n                },\n              },\n              grid: {\n                smooth: false,\n                style: {\n                  lineDash: [0],\n                },\n              },\n            },\n            {\n              orient: \"angle\",\n              zIndex: 50,\n              tick: {\n                visible: false,\n              },\n              domainLine: {\n                visible: false,\n              },\n              label: {\n                space: 20,\n              },\n              grid: {\n                style: {\n                  lineDash: [0],\n                },\n              },\n            },\n          ],\n        };\n\n        const gaugeSpec = {\n          width: 300,\n          height: 350,\n          padding: { top: 0, bottom: 0, left: 0, right: 0 },\n          type: \"common\",\n          data: [\n            {\n              id: \"pointer\",\n              values: [{ type: \"Score\", value: {{ exit_score | default(0) | round(0) }} }],\n            },\n            {\n              id: \"segment\",\n              values: [\n                { type: \"Complex\", value: 20 },\n                { type: \"Challenging\", value: 40 },\n                { type: \"Manageable\", value: 60 },\n                { type: \"Smooth Transition\", value: 80 },\n                { type: \"Seamless\", value: 100 },\n              ],\n            },\n          ],\n          series: [\n            {\n              type: \"gauge\",\n              dataIndex: 1,\n              radiusField: \"type\",\n              angleField: \"value\",\n              seriesField: \"type\",\n              outerRadius: 0.9,\n              innerRadius: 0.65,\n              roundCap: true,\n              segment: {\n                style: {\n                  cornerRadius: 500,\n                  innerPadding: 5,\n                  outerPadding: 5,\n                  fill: {\n                    type: \"threshold\",\n                    field: \"value\",\n                    domain: [20, 40, 60, 80, 100],\n                    range: [\n                      \"#ba1c1d\",\n                      \"#ba1c1d\",\n                      \"#ff9533\",\n                      \"#f1ca00\",\n                      \"#76c31d\",\n                      \"#065f43\",\n                    ],\n                  },\n                },\n              },\n              track: {\n                visible: true,\n                style: {\n                  cornerRadius: 500,\n                  roundCap: true,\n                  fill: \"rgba(0, 0, 0, 0.1)\",\n                },\n              },\n            },\n            {\n              type: \"gaugePointer\",\n              dataIndex: 0,\n              categoryField: \"type\",\n              valueField: \"value\",\n              innerRadius: 0.45,\n              pin: {\n                visible: true,\n                width: 0.04,\n                height: 0.04,\n                isOnCenter: false,\n                style: {\n                  fill: {\n                    type: \"threshold\",\n                    field: \"value\",\n                    domain: [20, 40, 60, 80, 100],\n                    range: [\"#012e53\"],\n                  },\n                },\n              },\n              pinBackground: { visible: false },\n              pointer: {\n                width: 0.2,\n                height: 0.1,\n                isOnCenter: false,\n                style: {\n                  fill: {\n                    type: \"threshold\",\n                    field: \"value\",\n                    domain: [20, 40, 60, 80, 100],\n                    range: [\"#012e53\"],\n                  },\n                },\n              },\n              animation: false,\n            },\n          ],\n          startAngle: -200,\n          endAngle: 20,\n          axes: [\n            {\n              type: \"linear\",\n              orient: \"angle\",\n              inside: true,\n              outerRadius: 0.9,\n              innerRadius: 0.6,\n              min: 0,\n              max: 100,\n              grid: { visible: false },\n              tick: {\n                visible: false,\n                tickSize: 0,\n                style: { lineWidth: 4, lineCap: \"round\" },\n              },\n              subTick: {\n                visible: false,\n                tickSize: 0,\n                style: { lineWidth: 4, lineCap: \"round\" },\n              },\n              label: { visible: false },\n            },\n            {\n              type: \"linear\",\n              orient: \"radius\",\n              outerRadius: 0.6,\n              grid: { visible: false },\n              label: { visible: false },\n            },\n          ],\n          indicator: [\n            {\n              visible: true,\n              offsetY: \"-10%\",\n              title: {\n                style: {\n                  text: \"{{ exit_score | default(0) | round(0) }}\",\n                  fontSize: 40,\n                  fontWeight: 500,\n                  fontColor: \"#012e53\",\n                },\n              },\n              content: [\n                {\n                  style: {\n                    dy: 10,\n                    text: \"Exit Score\",\n                    fontSize: 20,\n                  },\n                },\n              ],\n            },\n            {\n              visible: true,\n              offsetX: \"-70%\",\n              offsetY: \"45%\",\n              title: {\n                style: {\n                  text: \"0\",\n                  fontSize: 14,\n                },\n              },\n            },\n            {\n              visible: true,\n              offsetX: \"70%\",\n              offsetY: \"45%\",\n              title: {\n                style: {\n                  text: \"100\",\n                  fontSize: 14,\n                },\n              },\n            },\n          ],\n        };\n\n        const radarContainer = document.getElementById(\"vendorLockInScoreChart\");\n        const gaugeContainer = document.getElementById(\"exitScoreChart\");\n\n        if (radarContainer) {\n          const vendorLockInScoreChart = new VChart.default(radarSpec, {\n            dom: radarContainer,\n          });\n          vendorLockInScoreChart.renderSync();\n        }\n\n        if (gaugeContainer) {\n          const exitScoreChart = new VChart.default(gaugeSpec, {\n            dom: gaugeContainer,\n          });\n          exitScoreChart.renderSync();\n        }\n      </script>\n      {% endif %}\n    </main>\n  </body>\n</html>\n"
  },
  {
    "path": "config/aws_example.json",
    "content": "{\n    \"cloudServiceProvider\": 2,\n    \"exitStrategy\": 3,\n    \"assessmentType\": 1,\n    \"providerDetails\":{\n      \"accessKey\":\"AKIAXASFHMTLKD6YQLHA\",\n      \"secretKey\":\"\",\n      \"region\":\"eu-central-1\"\n   }\n}\n"
  },
  {
    "path": "config/azure_example.json",
    "content": "{\n    \"cloudServiceProvider\": 1,\n    \"exitStrategy\": 3,\n    \"assessmentType\": 1,\n    \"providerDetails\":{\n      \"clientId\":\"57344955-1579-4058-8604-5bb8724002de\",\n      \"clientSecret\":\"\",\n      \"tenantId\":\"38997009-9dad-42b2-b187-53f1cb71560e\",\n      \"subscriptionId\":\"\",\n      \"resourceGroupName:\":\"\"\n   }\n}\n"
  },
  {
    "path": "config.py",
    "content": "# config.py\n\"\"\"\nConfiguration for integrating the local 'cloudexit' tool with the ExitCloud Platform (exitcloud.io).\nThis enables assessment extension and secure result storage in your selected data region.\n\nHOST:\n  EU → \"eu.exitcloud.io\"\n  US → \"us.exitcloud.io\"\n\nKEY:\nTo generate a key:\n  1. Log in to your regional portal (https://eu.exitcloud.io or https://us.exitcloud.io).\n  2. Click your user profile (top right corner).\n  3. Select 'Keys' from the menu.\n  4. Click 'New Key' and copy the provided key.\n\nPlease do not modify CLI_VERSION; it is used for debugging purposes.\n\"\"\"\n\nCLI_VERSION = \"v1.0.0\"\n\nHOST = \"\"\nKEY = \"\"\n"
  },
  {
    "path": "core/__init__.py",
    "content": ""
  },
  {
    "path": "core/engine.py",
    "content": "# core/engine.py\nimport logging\nimport os\nimport boto3\nfrom datetime import datetime\nfrom typing import Any, Dict, Optional, Tuple\nfrom azure.identity import ClientSecretCredential\nfrom azure.mgmt.resource import ResourceManagementClient\nfrom azure.core.exceptions import ClientAuthenticationError\nfrom azure.mgmt.authorization import AuthorizationManagementClient\nfrom botocore.exceptions import NoCredentialsError\n\nfrom .utils import copy_assets\nfrom .utils_aws import build_aws_resource_inventory, build_aws_cost_inventory\nfrom .utils_azure import build_azure_resource_inventory, build_azure_cost_inventory\nfrom .utils_db import connect, load_data\nfrom .utils_report import (\n    generate_html_report,\n    generate_pdf_report,\n    generate_json_report,\n)\nfrom .utils_sync import post_assessment\n\n# Configure the logger\nlogger = logging.getLogger(\"core.engine\")\n\n\n# Stage 1\ndef verify_credentials(\n    cloud_service_provider: int, provider_details: Dict[str, Any]\n) -> Tuple[bool, str]:\n    connection_success = False\n    logs = \"\"\n\n    if cloud_service_provider == 1:  # Azure\n        try:\n            # Use DefaultAzureCredential if provided, else use client secrets\n            credential = provider_details.get(\"credential\") or ClientSecretCredential(\n                tenant_id=provider_details[\"tenantId\"],\n                client_id=provider_details[\"clientId\"],\n                client_secret=provider_details[\"clientSecret\"],\n            )\n            resource_client = ResourceManagementClient(\n                credential, provider_details[\"subscriptionId\"]\n            )\n            list(\n                resource_client.resource_groups.list()\n            )  # Benign call to verify credentials\n            connection_success = True\n            logs = \"Azure connection successful.\"\n        except ClientAuthenticationError as e:\n            logs = f\"Azure credentials validation failed: {str(e)}\"\n            # logger.error(logs)\n        except Exception as e:\n            logs = f\"Azure connection test failed: {str(e)}\"\n            # logger.error(logs)\n\n    elif cloud_service_provider == 2:  # AWS\n        try:\n            client = boto3.client(\n                \"ec2\",\n                aws_access_key_id=provider_details[\"accessKey\"],\n                aws_secret_access_key=provider_details[\"secretKey\"],\n                region_name=provider_details[\"region\"],\n            )\n            client.describe_regions()  # Benign call to verify credentials\n            connection_success = True\n            logs = \"AWS connection successful.\"\n        except NoCredentialsError as e:\n            logs = f\"AWS credentials validation failed: {str(e)}\"\n            # logger.error(logs)\n        except Exception as e:\n            logs = f\"AWS connection test failed: {str(e)}\"\n            # logger.error(logs)\n\n    return connection_success, logs\n\n\n# Stage 2\ndef test_permissions(\n    cloud_service_provider: int, provider_details: Dict[str, Any]\n) -> Tuple[bool, bool, bool, str]:\n    permission_valid = False\n    permission_reader = False\n    permission_cost = False\n    logs = \"\"\n\n    if cloud_service_provider == 1:  # Azure\n        try:\n            # Use DefaultAzureCredential if provided, else use client secrets\n            credential = provider_details.get(\"credential\") or ClientSecretCredential(\n                tenant_id=provider_details[\"tenantId\"],\n                client_id=provider_details[\"clientId\"],\n                client_secret=provider_details[\"clientSecret\"],\n            )\n            resource_group_scope = f\"/subscriptions/{provider_details['subscriptionId']}/resourceGroups/{provider_details['resourceGroupName']}\"\n\n            # Check role assignments\n            auth_client = AuthorizationManagementClient(\n                credential, provider_details[\"subscriptionId\"]\n            )\n            role_assignments = auth_client.role_assignments.list_for_scope(\n                scope=resource_group_scope\n            )\n\n            for role_assignment in role_assignments:\n                role_definition_id = role_assignment.role_definition_id\n                if role_definition_id.endswith(\n                    \"acdd72a7-3385-48ef-bd42-f606fba81ae7\"\n                ):  # Reader role\n                    permission_reader = True\n                if role_definition_id.endswith(\n                    \"72fafb9e-0641-4937-9268-a91bfd8191a3\"\n                ):  # Cost Management Reader\n                    permission_cost = True\n\n            if permission_reader and permission_cost:\n                permission_valid = True\n                logs = \"Reader and Cost Management Reader roles validated.\"\n            elif permission_reader:\n                logs = \"Reader role validated, but Cost Management Reader role validation failed.\"\n            elif permission_cost:\n                logs = \"Cost Management Reader role validated, but Reader role validation failed.\"\n            else:\n                logs = \"Both Reader and Cost Management Reader roles validation failed.\"\n\n        except ClientAuthenticationError as e:\n            logs = f\"Azure credentials validation failed: {str(e)}\"\n            logger.error(logs)\n        except Exception as e:\n            logs = f\"Azure permission test failed: {str(e)}\"\n            logger.error(logs)\n\n    elif cloud_service_provider == 2:  # AWS\n        try:\n            sts_client = boto3.client(\n                \"sts\",\n                aws_access_key_id=provider_details[\"accessKey\"],\n                aws_secret_access_key=provider_details[\"secretKey\"],\n                region_name=provider_details[\"region\"],\n            )\n            identity = sts_client.get_caller_identity()\n            user_arn = identity[\"Arn\"]\n            user_name = user_arn.split(\"/\")[-1]\n\n            iam_client = boto3.client(\n                \"iam\",\n                aws_access_key_id=provider_details[\"accessKey\"],\n                aws_secret_access_key=provider_details[\"secretKey\"],\n                region_name=provider_details[\"region\"],\n            )\n            policies = iam_client.list_attached_user_policies(UserName=user_name)\n            policy_names = [\n                policy[\"PolicyName\"] for policy in policies[\"AttachedPolicies\"]\n            ]\n\n            permission_reader = \"ViewOnlyAccess\" in policy_names\n            permission_cost = \"AWSBillingReadOnlyAccess\" in policy_names\n\n            if permission_reader and permission_cost:\n                permission_valid = True\n                logs = \"ViewOnlyAccess and AWSBillingReadOnlyAccess policies validated.\"\n            elif permission_reader:\n                logs = \"ViewOnlyAccess policy validated, but AWSBillingReadOnlyAccess policy validation failed.\"\n            elif permission_cost:\n                logs = \"AWSBillingReadOnlyAccess policy validated, but ViewOnlyAccess policy validation failed.\"\n            else:\n                logs = \"Both ViewOnlyAccess and AWSBillingReadOnlyAccess policy validations failed.\"\n\n        except NoCredentialsError as e:\n            logs = f\"AWS credentials validation failed: {str(e)}\"\n            logger.error(logs)\n        except Exception as e:\n            logs = f\"AWS permission test failed: {str(e)}\"\n            logger.error(logs)\n\n    permission_valid = permission_reader and permission_cost\n\n    return permission_valid, permission_reader, permission_cost, logs\n\n\n# Stage 3\ndef create_resource_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> Dict[str, Any]:\n    # Copy assets and datasets folders data\n    copy_assets(report_path)\n\n    try:\n\n        if cloud_service_provider == 1:  # Azure\n            build_azure_resource_inventory(\n                cloud_service_provider, provider_details, report_path, raw_data_path\n            )\n        elif cloud_service_provider == 2:  # AWS\n            build_aws_resource_inventory(\n                cloud_service_provider, provider_details, report_path, raw_data_path\n            )\n\n        return {\"success\": True, \"logs\": \"Resource inventory created successfully.\"}\n\n    except Exception as e:\n        logger.error(f\"Error creating resource inventory: {str(e)}\", exc_info=True)\n        # Do not raise the exception here; just return the error information\n        return {\"success\": False, \"logs\": str(e)}\n\n\n# Stage 4\ndef create_cost_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> Dict[str, Any]:\n    try:\n        if cloud_service_provider == 1:  # Azure\n            build_azure_cost_inventory(\n                cloud_service_provider, provider_details, report_path, raw_data_path\n            )\n        elif cloud_service_provider == 2:  # AWS\n            build_aws_cost_inventory(\n                cloud_service_provider, provider_details, report_path, raw_data_path\n            )\n\n        return {\"success\": True, \"logs\": \"Cost inventory created successfully.\"}\n\n    except Exception as e:\n        logger.error(f\"Error creating cost inventory: {str(e)}\", exc_info=True)\n        return {\"success\": False, \"logs\": str(e)}\n\n\n# Stage 5 - Online\ndef sync_assessment(\n    report_path: str,\n    name: str,\n    started_at: int,\n    metadata: Dict[str, Any],\n    mode: str,\n    token: Optional[str],\n) -> Dict[str, Any]:\n    if mode != \"online\" or not token:\n        return {\n            \"success\": True,\n            \"online\": False,\n            \"payload\": None,\n            \"logs\": \"offline – sync skipped.\",\n        }\n\n    result = post_assessment(\n        name=name,\n        started_at=started_at,\n        report_path=report_path,\n        meta=metadata,\n        token=token,\n    )\n\n    if not result.get(\"success\"):\n        raise RuntimeError(f\"Assessment sync failed: {result.get('logs')}\")\n\n    logger.debug(result)\n\n    try:\n        payload = result[\"payload\"].get(\"data\", {})\n        server_risks = payload.get(\"risk_inventory\", [])\n\n        rows = []\n        for entry in server_risks:\n            rid = entry[\"id\"]\n            impacted = entry.get(\"impacted_resources\", [])\n            if impacted:\n                for rt in impacted:\n                    rows.append((rt, rid))\n            else:\n                rows.append((\"null\", rid))\n\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n            cursor.executemany(\n                \"\"\"\n                INSERT INTO risk_inventory (resource_type, risk)\n                VALUES (?, ?)\n                \"\"\",\n                rows,\n            )\n            conn.commit()\n\n    except Exception as e:\n        logger.error(\"Error saving server risks to local DB: %s\", str(e), exc_info=True)\n        raise RuntimeError(f\"Failed to store server risks: {str(e)}\")\n\n    try:\n        scoring = payload.get(\"scoring_data\")\n        if scoring:\n            db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n            with connect(db_path=db_path) as conn:\n                cursor = conn.cursor()\n                cursor.execute(\n                    \"\"\"\n                    INSERT INTO scoring_data (exit_score, human_score, technology_score, operational_score)\n                    VALUES (?, ?, ?, ?)\n                    \"\"\",\n                    (\n                        int(scoring[\"exit_score\"]),\n                        int(scoring[\"human_score\"]),\n                        int(scoring[\"technology_score\"]),\n                        int(scoring[\"operational_score\"]),\n                    ),\n                )\n                conn.commit()\n                logger.debug(\"Scoring data saved to local DB.\")\n\n    except Exception as e:\n        logger.error(\"Error saving scoring data to local DB: %s\", str(e), exc_info=True)\n        raise RuntimeError(f\"Failed to store scoring data: {str(e)}\")\n\n    return result\n\n\n# Stage 5 - Offline\ndef perform_risk_assessment(\n    exit_strategy: int, report_path: str, mode: str\n) -> Dict[str, Any]:\n\n    if mode != \"offline\":\n        logger.debug(\"Online mode – skipping local risk assessment.\")\n        return {\"success\": True, \"logs\": \"online mode – local risk skipped.\"}\n\n    try:\n        # Define the database path\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        # Load data from the database\n        resource_inventory = load_data(\"resource_inventory\", db_path=db_path)\n        alternatives = load_data(\"alternative\", db_path=db_path)\n        alternative_technologies = load_data(\"alternativetechnology\", db_path=db_path)\n\n        # Initialize risk inventory\n        risk_inventory = []\n\n        # Calculate the total count of resources across all types\n        total_resource_count = sum(item[\"count\"] for item in resource_inventory)\n\n        # Calculate total number of distinct resource types\n        distinct_resource_types = set(\n            item[\"resource_type\"] for item in resource_inventory\n        )\n        total_resource_types = len(distinct_resource_types)\n\n        # Process each resource by `resource_type`\n        for resource_data in resource_inventory:\n            resource_type_id = str(\n                resource_data[\"resource_type\"]\n            )  # Convert to string for consistent comparison\n\n            # Filter alternatives for the current resource_type and exit strategy\n            relevant_alternatives = [\n                alt\n                for alt in alternatives\n                if str(alt[\"resource_type\"]) == resource_type_id\n                and str(alt[\"strategy_type\"]) == str(exit_strategy)\n            ]\n            alternative_count = len(relevant_alternatives)\n\n            # Count alternatives with support\n            support_count = sum(\n                1\n                for alt in relevant_alternatives\n                if any(\n                    tech[\"id\"] == alt[\"alternative_technology\"]\n                    and tech[\"support_plan\"] == \"t\"\n                    for tech in alternative_technologies\n                )\n            )\n\n            # Determine risks based on criteria, using resource_type_id in output\n            if 1 <= alternative_count < 3:\n                risk_inventory.append({\"resource_type\": resource_type_id, \"risk\": \"1\"})\n            if alternative_count == 0:\n                risk_inventory.append({\"resource_type\": resource_type_id, \"risk\": \"2\"})\n            if 1 <= support_count < 3:\n                risk_inventory.append({\"resource_type\": resource_type_id, \"risk\": \"3\"})\n            if support_count == 0:\n                risk_inventory.append({\"resource_type\": resource_type_id, \"risk\": \"4\"})\n\n        # Check for risks based on total resource count across all types\n        if 15 < total_resource_count <= 30:\n            risk_inventory.append({\"resource_type\": \"null\", \"risk\": \"5\"})\n        elif total_resource_count > 30:\n            risk_inventory.append({\"resource_type\": \"null\", \"risk\": \"6\"})\n\n        # Check for risks based on total number of resource types\n        if 15 < total_resource_types <= 30:\n            risk_inventory.append({\"resource_type\": \"null\", \"risk\": \"7\"})\n        elif total_resource_types > 30:\n            risk_inventory.append({\"resource_type\": \"null\", \"risk\": \"8\"})\n\n        # Insert risk inventory into the database\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n            cursor.executemany(\n                \"\"\"\n                INSERT INTO risk_inventory (resource_type, risk)\n                VALUES (?, ?)\n                \"\"\",\n                [(entry[\"resource_type\"], entry[\"risk\"]) for entry in risk_inventory],\n            )\n            conn.commit()\n\n        return {\"success\": True, \"logs\": \"Risk assessment completed successfully.\"}\n\n    except Exception as e:\n        logger.error(f\"Error performing risk assessment: {str(e)}\", exc_info=True)\n        return {\"success\": False, \"logs\": str(e)}\n\n\n# Stage 6\ndef generate_report(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    exit_strategy: int,\n    assessment_type: int,\n    name: str,\n    report_path: str,\n    raw_data_path: str,\n) -> Dict[str, Any]:\n    try:\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        # Load data\n        resource_type_mapping = {\n            str(item[\"id\"]): item for item in load_data(\"resourcetype\", db_path=db_path)\n        }\n        risk_definitions = load_data(\"risk\", db_path=db_path)\n        alternatives = load_data(\"alternative\", db_path=db_path)\n        alternative_technologies = load_data(\"alternativetechnology\", db_path=db_path)\n        resource_inventory = load_data(\"resource_inventory\", db_path=db_path)\n        cost_data = load_data(\"cost_inventory\", db_path=db_path)\n        risk_data = load_data(\"risk_inventory\", db_path=db_path)\n        scoring_data = load_data(\"scoring_data\", db_path=db_path)\n\n        # Timestamp\n        timestamp = datetime.utcnow().strftime(\"%Y-%m-%d %H:%M:%S UTC\")\n\n        metadata = {\n            \"name\": name,\n            \"cloud_service_provider\": cloud_service_provider,\n            \"exit_strategy\": exit_strategy,\n            \"assessment_type\": assessment_type,\n            \"timestamp\": timestamp,\n        }\n\n        # Handle scoring_data\n        if isinstance(scoring_data, list):\n            if len(scoring_data) == 1:\n                scoring_data = scoring_data[0]\n            elif len(scoring_data) == 0:\n                scoring_data = None\n            else:\n                logger.warning(\n                    \"Unexpected multiple rows in scoring_data: %d\", len(scoring_data)\n                )\n                scoring_data = scoring_data[0]\n\n        # Generate Outputs\n        reports = {}\n\n        # Generate HTML report\n        reports[\"HTML\"] = generate_html_report(\n            report_path,\n            metadata,\n            resource_type_mapping,\n            resource_inventory,\n            cost_data,\n            scoring_data,\n            risk_data,\n            risk_definitions,\n            alternatives,\n            alternative_technologies,\n            exit_strategy,\n        )\n\n        # Generate PDF report\n        reports[\"PDF\"] = generate_pdf_report(\n            provider_details,\n            report_path,\n            metadata,\n            resource_type_mapping,\n            resource_inventory,\n            cost_data,\n            scoring_data,\n            risk_data,\n            risk_definitions,\n            alternatives,\n            alternative_technologies,\n            exit_strategy,\n        )\n\n        # Generate JSON report\n        reports[\"JSON\"] = generate_json_report(\n            raw_data_path,\n            metadata,\n            resource_type_mapping,\n            resource_inventory,\n            cost_data,\n            scoring_data,\n            risk_data,\n            risk_definitions,\n            alternatives,\n            alternative_technologies,\n            exit_strategy,\n        )\n\n        return {\"success\": True, \"reports\": reports}\n\n    except Exception as e:\n        return {\"success\": False, \"logs\": f\"Error generating report: {str(e)}\"}\n"
  },
  {
    "path": "core/utils.py",
    "content": "# core/utils.py\nimport os\nimport shutil\nimport logging\n\nlogger = logging.getLogger(\"core.engine.utils\")\n\n\ndef copy_assets(report_path: str) -> None:\n    assets_folders = [\"css\", \"icons\", \"img\"]\n    assets_path = os.path.join(report_path, \"assets\")\n\n    # Create the 'assets' directory if it doesn't exist\n    os.makedirs(assets_path, exist_ok=True)\n\n    for folder in assets_folders:\n        src_path = os.path.join(\"assets\", folder)\n        dest_path = os.path.join(assets_path, folder)\n\n        # Only copy if the destination doesn't already exist\n        if not os.path.exists(dest_path):\n            shutil.copytree(src_path, dest_path, dirs_exist_ok=True)\n\n    # Copy datasets/data.db to data/assessment.db\n    db_src_path = \"datasets/data.db\"\n    db_dest_dir = os.path.join(report_path, \"data\")\n    db_dest_path = os.path.join(db_dest_dir, \"assessment.db\")\n\n    # Create the 'data' directory if it doesn't exist\n    os.makedirs(db_dest_dir, exist_ok=True)\n\n    # Only copy if the destination doesn't already exist\n    if not os.path.exists(db_dest_path):\n        shutil.copyfile(db_src_path, db_dest_path)\n"
  },
  {
    "path": "core/utils_aws.py",
    "content": "# core/utils_aws.py\nimport boto3\nimport botocore\nimport json\nimport os\nimport time\nimport logging\nimport sqlite3\nfrom typing import Any, Dict, Set, List, Callable\nfrom datetime import date, datetime\nfrom collections import defaultdict\nfrom dateutil.relativedelta import relativedelta\nfrom botocore.exceptions import NoCredentialsError, ClientError\n\nfrom .utils_db import connect, load_data\n\nlogger = logging.getLogger(\"core.engine.aws\")\n\n\ndef aws_api_call_with_retry(\n    client: Any,\n    function_name: str,\n    parameters: Dict[str, Any],\n    max_retries: int,\n    retry_delay: int,\n) -> Callable[..., Any]:\n    def api_call(*args, **kwargs):\n        for attempt in range(max_retries):\n            try:\n                function_to_call = getattr(client, function_name)\n                if parameters:\n                    return function_to_call(**parameters, **kwargs)\n                else:\n                    return function_to_call(**kwargs)\n            except botocore.exceptions.ClientError as error:\n                error_code = error.response[\"Error\"][\"Code\"]\n                # logger.warning(f\"ClientError: {error_code}. Attempt {attempt + 1} of {max_retries}. Retrying in {retry_delay} seconds.\")\n                if error_code in [\"Throttling\", \"RequestLimitExceeded\"]:\n                    time.sleep(retry_delay * (2**attempt))\n                    continue\n                else:\n                    raise\n            except botocore.exceptions.BotoCoreError:\n                # logger.warning(f\"BotoCoreError. Attempt {attempt + 1} of {max_retries}. Retrying in {retry_delay} seconds.\")\n                time.sleep(retry_delay * (2**attempt))\n                continue\n        raise Exception(f\"Failed to call {function_name} after {max_retries} attempts\")\n\n    return api_call  # Return the callable function\n\n\ndef convert_datetime(obj: Any) -> Any:\n    if isinstance(obj, dict):\n        for k, v in obj.items():\n            obj[k] = convert_datetime(v)\n    elif isinstance(obj, list):\n        for i in range(len(obj)):\n            obj[i] = convert_datetime(obj[i])\n    elif isinstance(obj, datetime):\n        return obj.isoformat()\n    return obj\n\n\ndef build_aws_resource_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> None:\n    try:\n        access_key = provider_details[\"accessKey\"]\n        secret_key = provider_details[\"secretKey\"]\n        region = provider_details[\"region\"]\n\n        session = boto3.Session(\n            aws_access_key_id=access_key,\n            aws_secret_access_key=secret_key,\n            region_name=region,\n        )\n\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        # Load the ResourceType mapping\n        resource_type_mapping = {\n            item[\"code\"]: {\"id\": item[\"id\"], \"name\": item[\"name\"]}\n            for item in load_data(\"resourcetype\")\n            if item[\"csp\"] == 2 and item[\"status\"] == \"t\"\n        }\n\n        # Save raw data for debugging and auditing purposes\n        raw_data = []\n\n        # Aggregate resources by type and location\n        aggregated_resources = defaultdict(int)\n\n        # Iterate through each resource type in the JSON\n        for idx, (resource_type_code, resource_info) in enumerate(\n            resource_type_mapping.items(), start=1\n        ):\n            parts = resource_type_code.split(\".\")\n            if len(parts) != 4 or parts[0] != \"AWS\":\n                # logger.warning(f\"Invalid resource type format: {resource_type_code}. Skipping.\")\n                continue\n\n            # Extract service name, operation name, and result key\n            service_name, operation_name, result_key = parts[1], parts[2], parts[3]\n\n            # logger.info(f\"Processing service {service_name} with operation {operation_name}\")\n\n            try:\n                client = session.client(service_name, region_name=region)\n                if not hasattr(client, operation_name):\n                    # logger.error(f\"Operation {operation_name} does not exist for service {service_name}\")\n                    continue\n\n                # Make the API call\n                api_call = aws_api_call_with_retry(\n                    client, operation_name, {}, max_retries=3, retry_delay=2\n                )\n                response = api_call()\n\n                if isinstance(response, dict):\n                    response.pop(\"ResponseMetadata\", None)\n                    resources = response.get(result_key.strip(), [])\n                    # Handle paginated results\n                    while \"NextToken\" in response:\n                        next_token = response[\"NextToken\"]\n                        response = api_call(NextToken=next_token)\n                        response.pop(\"ResponseMetadata\", None)\n                        resources.extend(response.get(result_key.strip(), []))\n                else:\n                    # logger.warning(f\"No valid response found for {service_name} operation {operation_name}. Skipping.\")\n                    continue\n\n                # Aggregate the resources\n                for resource in resources:\n                    aggregated_resources[(resource_type_code, region)] += 1\n\n                # Store raw data\n                raw_data.append(\n                    {\n                        \"service\": service_name,\n                        \"operation\": operation_name,\n                        \"resources\": resources,\n                    }\n                )\n\n            except (NoCredentialsError, ClientError, Exception):\n                # logger.error(f\"Error while processing {service_name}\", exc_info=True)\n                continue\n\n        # Save raw data to a JSON file\n        raw_data = convert_datetime(raw_data)\n\n        raw_file_path = os.path.join(raw_data_path, \"resource_inventory_raw_data.json\")\n        with open(raw_file_path, \"w\", encoding=\"utf-8\") as raw_file:\n            json.dump(raw_data, raw_file, indent=4)\n\n        # Insert aggregated data into SQLite\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n\n            for (\n                resource_type_code,\n                resource_location,\n            ), resource_count in aggregated_resources.items():\n                try:\n                    # Map resource type code to resource_type_id\n                    resource_info = resource_type_mapping.get(resource_type_code)\n                    if not resource_info:\n                        # logger.warning(f\"Resource type {resource_type_code} not found in resourcetype mapping. Skipping.\")\n                        continue\n\n                    resource_type_id = resource_info[\"id\"]\n\n                    cursor.execute(\n                        \"\"\"\n                        INSERT INTO resource_inventory (resource_type, location, count)\n                        VALUES (?, ?, ?)\n                        ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count\n                        \"\"\",\n                        (resource_type_id, resource_location, resource_count),\n                    )\n                except sqlite3.Error as e:\n                    logger.error(\n                        f\"SQLite error while processing aggregated resource: {e}\",\n                        exc_info=True,\n                    )\n                except Exception as e:\n                    logger.error(\n                        f\"Unexpected error while processing aggregated resource: {e}\",\n                        exc_info=True,\n                    )\n\n            conn.commit()\n\n    except Exception as e:\n        logger.error(f\"Error creating AWS resource inventory: {str(e)}\", exc_info=True)\n\n\ndef get_missing_months_aws(processed_costs: Set[str], max_months: int) -> List[date]:\n    current_date = datetime.utcnow().date().replace(day=1)\n    processed_months = {\n        datetime.strptime(month_str, \"%Y-%m-%d\").date().replace(day=1)\n        for month_str in processed_costs\n    }\n    missing_months = []\n\n    for i in range(max_months):\n        check_date = current_date - relativedelta(months=i)\n        if check_date not in processed_months:\n            missing_months.append(check_date)\n\n    return missing_months\n\n\ndef build_aws_cost_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> None:\n    try:\n        session = boto3.Session(\n            aws_access_key_id=provider_details[\"accessKey\"],\n            aws_secret_access_key=provider_details[\"secretKey\"],\n            region_name=provider_details[\"region\"],\n        )\n        cost_explorer = session.client(\"ce\", region_name=\"us-east-1\")\n\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        end_time = date.today().replace(day=1) + relativedelta(months=1)\n        start_time = end_time - relativedelta(months=6)\n\n        cost_and_usage = cost_explorer.get_cost_and_usage(\n            TimePeriod={\n                \"Start\": start_time.strftime(\"%Y-%m-%d\"),\n                \"End\": end_time.strftime(\"%Y-%m-%d\"),\n            },\n            Granularity=\"MONTHLY\",\n            Metrics=[\"UnblendedCost\"],\n            GroupBy=[{\"Type\": \"DIMENSION\", \"Key\": \"SERVICE\"}],\n            Filter={\n                \"Dimensions\": {\"Key\": \"REGION\", \"Values\": [provider_details[\"region\"]]}\n            },\n        )\n\n        cost_inventory_raw_path = os.path.join(\n            raw_data_path, \"cost_inventory_raw_data.json\"\n        )\n        with open(cost_inventory_raw_path, \"w\", encoding=\"utf-8\") as raw_file:\n            json.dump(cost_and_usage, raw_file, indent=4)\n\n        # Insert structured data into SQLite\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n\n            for result in cost_and_usage[\"ResultsByTime\"]:\n                month_str = result[\"TimePeriod\"][\"Start\"]\n                total_cost = sum(\n                    float(group[\"Metrics\"][\"UnblendedCost\"][\"Amount\"])\n                    for group in result[\"Groups\"]\n                )\n                currency = (\n                    result[\"Groups\"][0][\"Metrics\"][\"UnblendedCost\"][\"Unit\"]\n                    if result[\"Groups\"]\n                    else \"USD\"\n                )\n                month_date = (\n                    datetime.strptime(month_str, \"%Y-%m-%d\")\n                    .date()\n                    .replace(day=1)\n                    .isoformat()\n                )\n\n                # Insert or update the cost data for the month\n                cursor.execute(\n                    \"\"\"\n                    INSERT INTO cost_inventory (month, cost, currency)\n                    VALUES (?, ?, ?)\n                    ON CONFLICT(month) DO UPDATE SET\n                        cost = excluded.cost,\n                        currency = excluded.currency\n                    \"\"\",\n                    (month_date, total_cost, currency),\n                )\n\n            # Handle missing months\n            structured_months = {\n                datetime.strptime(result[\"TimePeriod\"][\"Start\"], \"%Y-%m-%d\").date()\n                for result in cost_and_usage[\"ResultsByTime\"]\n            }\n            missing_months = get_missing_months_aws(\n                {month.isoformat() for month in structured_months}, 6\n            )\n\n            for missing_month in missing_months:\n                cursor.execute(\n                    \"\"\"\n                    INSERT INTO cost_inventory (month, cost, currency)\n                    VALUES (?, 0.00, ?)\n                    ON CONFLICT(month) DO UPDATE SET\n                        currency = excluded.currency\n                    \"\"\",\n                    (missing_month.isoformat(), currency),\n                )\n\n            conn.commit()\n\n    except sqlite3.Error as e:\n        logger.error(f\"SQLite error: {str(e)}\", exc_info=True)\n    except Exception as e:\n        logger.error(f\"Error creating AWS cost inventory: {str(e)}\", exc_info=True)\n        raise\n\n    except Exception as e:\n        logger.error(f\"Error creating AWS cost inventory: {str(e)}\", exc_info=True)\n        raise\n"
  },
  {
    "path": "core/utils_azure.py",
    "content": "# core/utils_azure.py\nimport json\nimport os\nimport logging\nimport sqlite3\nfrom typing import Any, Dict, Set\nfrom datetime import date, datetime\nfrom dateutil.relativedelta import relativedelta\nfrom collections import defaultdict\nfrom azure.identity import ClientSecretCredential\nfrom azure.mgmt.resource import ResourceManagementClient\nfrom azure.mgmt.costmanagement import CostManagementClient\nfrom azure.mgmt.costmanagement.models import QueryDefinition, TimeframeType\nfrom azure.core.exceptions import AzureError, ClientAuthenticationError\n\nfrom .utils_db import connect, load_data\n\nlogger = logging.getLogger(\"core.engine.azure\")\nlogging.getLogger(\"azure\").setLevel(logging.WARNING)\n\n\ndef is_resource_inventory_empty(\n    credential: Any, subscription_id: str, resource_group_name: str\n) -> bool:\n    try:\n        resource_client = ResourceManagementClient(credential, subscription_id)\n        # logger.info(\"Checking Azure resource inventory...\")\n        resources = list(\n            resource_client.resources.list_by_resource_group(resource_group_name)\n        )\n        if not resources:\n            # logger.info(\"No resources found in the resource group.\")\n            return True\n        else:\n            # logger.info(\"Resources found in the resource group.\")\n            return False\n    except AzureError as e:\n        logger.error(\n            f\"Error checking Azure resource inventory: {str(e)}\", exc_info=True\n        )\n        raise\n\n\ndef build_azure_resource_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> None:\n    try:\n        # Use DefaultAzureCredential if provided, otherwise fall back to ClientSecretCredential\n        credential = provider_details.get(\"credential\") or ClientSecretCredential(\n            tenant_id=provider_details[\"tenantId\"],\n            client_id=provider_details[\"clientId\"],\n            client_secret=provider_details[\"clientSecret\"],\n        )\n        subscription_id = provider_details[\"subscriptionId\"]\n        resource_group_name = provider_details[\"resourceGroupName\"]\n\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        # Check if resource inventory is empty\n        if is_resource_inventory_empty(\n            credential, subscription_id, resource_group_name\n        ):\n            logger.warning(\n                \"The selected resource group does not contain any resources.\"\n            )\n            return\n\n        resource_client = ResourceManagementClient(credential, subscription_id)\n\n        # Fetch resources and serialize to raw JSON\n        resources = list(\n            resource_client.resources.list_by_resource_group(resource_group_name)\n        )\n        raw_data = [resource.serialize(True) for resource in resources]\n\n        # Save raw data to a JSON file\n        raw_file_path = os.path.join(raw_data_path, \"resource_inventory_raw_data.json\")\n        with open(raw_file_path, \"w\", encoding=\"utf-8\") as raw_file:\n            json.dump(raw_data, raw_file, indent=4)\n\n        # Load resource type mapping from the assessment database\n        resource_type_mapping = getattr(\n            build_azure_resource_inventory, \"_resource_type_cache\", None\n        )\n        if resource_type_mapping is None:\n            resource_type_mapping = {\n                item[\"code\"].strip().lower(): {\"id\": item[\"id\"], \"name\": item[\"name\"]}\n                for item in load_data(\"resourcetype\", db_path=db_path)\n                if item[\"csp\"] == 1 and item[\"status\"] == \"t\"\n            }\n            build_azure_resource_inventory._resource_type_cache = resource_type_mapping\n\n        # Aggregate resources by type and location\n        aggregated_resources = defaultdict(int)\n        for resource in resources:\n            resource_type_code = resource.type.strip().lower()\n            resource_location = resource.location.strip().lower()\n            aggregated_resources[(resource_type_code, resource_location)] += 1\n\n        # Insert data into SQLite\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n            data_to_insert = [\n                (\n                    resource_type_mapping[resource_type_code][\"id\"],\n                    resource_location,\n                    resource_count,\n                )\n                for (\n                    resource_type_code,\n                    resource_location,\n                ), resource_count in aggregated_resources.items()\n                if resource_type_code in resource_type_mapping\n            ]\n            cursor.executemany(\n                \"\"\"\n                INSERT INTO resource_inventory (resource_type, location, count)\n                VALUES (?, ?, ?)\n                ON CONFLICT(resource_type, location) DO UPDATE SET count = excluded.count\n                \"\"\",\n                data_to_insert,\n            )\n            conn.commit()\n\n    except ClientAuthenticationError as e:\n        logger.error(f\"Azure authentication error: {str(e)}\", exc_info=True)\n    except sqlite3.Error as e:\n        logger.error(f\"SQLite error: {str(e)}\", exc_info=True)\n    except Exception as e:\n        logger.error(f\"Error fetching Azure resources: {str(e)}\", exc_info=True)\n\n\ndef get_missing_months_azure(processed_costs: Set[str], months_back: int) -> Set[date]:\n    today = date.today()\n    start_date = today.replace(day=1) - relativedelta(months=months_back - 1)\n    all_months = {\n        (start_date + relativedelta(months=i)).replace(day=1)\n        for i in range(months_back)\n    }\n\n    processed_months = set()\n    for month_str in processed_costs:\n        try:\n            # Attempt parsing with full timestamp format\n            month_date = (\n                datetime.strptime(month_str, \"%Y-%m-%dT%H:%M:%S\").date().replace(day=1)\n            )\n        except ValueError:\n            # Fallback to date-only format if full timestamp fails\n            month_date = datetime.strptime(month_str, \"%Y-%m-%d\").date().replace(day=1)\n        processed_months.add(month_date)\n\n    return all_months - processed_months\n\n\ndef build_azure_cost_inventory(\n    cloud_service_provider: int,\n    provider_details: Dict[str, Any],\n    report_path: str,\n    raw_data_path: str,\n) -> None:\n    try:\n        # Use DefaultAzureCredential if provided, otherwise fall back to ClientSecretCredential\n        credential = provider_details.get(\"credential\") or ClientSecretCredential(\n            tenant_id=provider_details[\"tenantId\"],\n            client_id=provider_details[\"clientId\"],\n            client_secret=provider_details[\"clientSecret\"],\n        )\n        cost_management_client = CostManagementClient(\n            credential, base_url=\"https://management.azure.com\"\n        )\n\n        db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n        end_time = date.today()\n        months_back = 6\n        start_time = end_time.replace(day=1) - relativedelta(months=months_back - 1)\n\n        query = QueryDefinition(\n            type=\"Usage\",\n            timeframe=TimeframeType.CUSTOM,\n            time_period={\n                \"from\": start_time.strftime(\"%Y-%m-%dT00:00:00Z\"),\n                \"to\": end_time.strftime(\"%Y-%m-%dT00:00:00Z\"),\n            },\n            dataset={\n                \"granularity\": \"Monthly\",\n                \"aggregation\": {\"totalCost\": {\"name\": \"Cost\", \"function\": \"Sum\"}},\n            },\n        )\n\n        cost_data = cost_management_client.query.usage(\n            f'/subscriptions/{provider_details[\"subscriptionId\"]}/resourceGroups/{provider_details[\"resourceGroupName\"]}',\n            query,\n        )\n\n        cost_inventory_raw_path = os.path.join(\n            raw_data_path, \"cost_inventory_raw_data.json\"\n        )\n        with open(cost_inventory_raw_path, \"w\", encoding=\"utf-8\") as raw_file:\n            json.dump(cost_data.as_dict(), raw_file, indent=4)\n\n        # Insert structured cost data into SQLite\n        with connect(db_path=db_path) as conn:\n            cursor = conn.cursor()\n\n            for row in cost_data.rows:\n                cost, month_str, currency = row\n                month_date = (\n                    datetime.strptime(month_str, \"%Y-%m-%dT%H:%M:%S\")\n                    .date()\n                    .replace(day=1)\n                    .isoformat()\n                )\n\n                # Insert or update cost data\n                cursor.execute(\n                    \"\"\"\n                    INSERT INTO cost_inventory (month, cost, currency)\n                    VALUES (?, ?, ?)\n                    ON CONFLICT(month) DO UPDATE SET\n                        cost = excluded.cost,\n                        currency = excluded.currency\n                    \"\"\",\n                    (month_date, cost, currency),\n                )\n\n            # Extract months already in the cost data\n            structured_months = {\n                datetime.strptime(row[1], \"%Y-%m-%dT%H:%M:%S\").date()\n                for row in cost_data.rows\n            }\n\n            # Identify missing months and insert with zero cost\n            missing_months = get_missing_months_azure(\n                {month.isoformat() for month in structured_months}, 6\n            )\n            for missing_month in missing_months:\n                cursor.execute(\n                    \"\"\"\n                    INSERT INTO cost_inventory (month, cost, currency)\n                    VALUES (?, 0.00, ?)\n                    ON CONFLICT(month) DO UPDATE SET\n                        currency = excluded.currency\n                    \"\"\",\n                    (missing_month.isoformat(), currency),\n                )\n\n            conn.commit()\n\n    except sqlite3.Error as e:\n        logger.error(f\"SQLite error: {str(e)}\", exc_info=True)\n    except Exception as e:\n        logger.error(f\"Error creating Azure cost inventory: {str(e)}\", exc_info=True)\n        raise\n"
  },
  {
    "path": "core/utils_db.py",
    "content": "# core/utils_db.py\nimport sqlite3\nimport logging\n\n# Configure logger for database operations\nlogger = logging.getLogger(\"core.engine.db\")\nlogger.setLevel(logging.INFO)\n\n# Default master database\nMASTER_DATABASE = \"datasets/data.db\"\n\n\ndef connect(db_path=MASTER_DATABASE):\n    try:\n        conn = sqlite3.connect(db_path)\n        return conn\n    except sqlite3.Error as e:\n        logger.error(f\"Error connecting to database: {e}\")\n        raise\n\n\ndef load_data(table_name, db_path=MASTER_DATABASE):\n    try:\n        conn = connect(db_path)\n        cursor = conn.cursor()\n        cursor.execute(f\"SELECT * FROM {table_name}\")\n        columns = [desc[0] for desc in cursor.description]\n        rows = cursor.fetchall()\n        conn.close()\n        return [dict(zip(columns, row)) for row in rows]\n    except sqlite3.Error as e:\n        logger.error(f\"Error loading data from table '{table_name}': {e}\")\n        raise\n\n\ndef execute_query(query, params=None, db_path=MASTER_DATABASE):\n    try:\n        conn = connect(db_path)\n        cursor = conn.cursor()\n        cursor.execute(query, params or ())\n        conn.commit()\n        rowcount = cursor.rowcount\n        conn.close()\n        return rowcount\n    except sqlite3.Error as e:\n        logger.error(f\"Error executing query: {e}\")\n        raise\n\n\ndef fetch_one(query, params=None, db_path=MASTER_DATABASE):\n    try:\n        conn = connect(db_path)\n        cursor = conn.cursor()\n        cursor.execute(query, params or ())\n        row = cursor.fetchone()\n        columns = [desc[0] for desc in cursor.description]\n        conn.close()\n        return dict(zip(columns, row)) if row else None\n    except sqlite3.Error as e:\n        logger.error(f\"Error fetching data: {e}\")\n        raise\n\n\ndef fetch_all(query, params=None, db_path=MASTER_DATABASE):\n    try:\n        conn = connect(db_path)\n        cursor = conn.cursor()\n        cursor.execute(query, params or ())\n        rows = cursor.fetchall()\n        columns = [desc[0] for desc in cursor.description]\n        conn.close()\n        return [dict(zip(columns, row)) for row in rows]\n    except sqlite3.Error as e:\n        logger.error(f\"Error fetching data: {e}\")\n        raise\n"
  },
  {
    "path": "core/utils_report.py",
    "content": "# core/utils_report.py\nimport os\nimport json\nimport logging\nfrom typing import List, Dict, Any, Optional\nfrom jinja2 import Template\n\n# ReportLab\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.lib.units import cm\nfrom reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle\nfrom reportlab.lib import colors\nfrom reportlab.lib.colors import HexColor\nfrom reportlab.platypus import (\n    SimpleDocTemplate,\n    Paragraph,\n    Spacer,\n    PageBreak,\n    Image,\n    Table,\n    TableStyle,\n)\n\n# Utils\nfrom core.utils_report_html import (\n    transform_cost_inventory_for_html,\n    transform_risk_inventory_for_html,\n    transform_alt_tech_for_html,\n)\nfrom core.utils_report_json import (\n    transform_resource_inventory_for_json,\n    transform_cost_inventory_for_json,\n    transform_risk_inventory_for_json,\n    transform_alt_tech_for_json,\n)\nfrom core.utils_report_pdf import (\n    transform_resource_inventory_for_pdf,\n    transform_cost_inventory_for_pdf,\n    transform_risk_inventory_for_pdf,\n    transform_alt_tech_for_pdf,\n    draw_header_footer,\n    draw_risk_chart,\n    draw_cost_chart,\n    draw_vendor_lockin_radar_chart,\n    draw_exitscore_chart,\n)\n\n# Configure logger\nlogger = logging.getLogger(\"core.engine.report\")\nlogger.setLevel(logging.INFO)\n\n\ndef anonymize_string(s: str, num_visible: int = 4) -> str:\n    if not isinstance(s, str):\n        return \"N/A\"\n\n    if len(s) <= 2 * num_visible:\n        return \"*\" * len(s)\n\n    middle_length = len(s) - 2 * num_visible\n    return f\"{s[:num_visible]}{'*' * middle_length}{s[-num_visible:]}\"\n\n\ndef generate_html_report(\n    report_path: str,\n    metadata: Dict[str, Any],\n    resource_type_mapping: Dict[str, Dict[str, Any]],\n    resource_inventory: List[Dict[str, Any]],\n    cost_data: List[Dict[str, Any]],\n    scoring_data: Optional[Dict[str, Any]],\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> str:\n\n    # Transform resource inventory\n    resource_inventory_dict = {\n        str(item[\"resource_type\"]): {\n            **item,\n            \"name\": resource_type_mapping.get(str(item[\"resource_type\"]), {}).get(\n                \"name\", \"Unknown Resource\"\n            ),\n            \"icon\": \"/assets\"\n            + resource_type_mapping.get(str(item[\"resource_type\"]), {}).get(\n                \"icon\", \"/icons/default.png\"\n            ),\n        }\n        for item in resource_inventory\n    }\n\n    # Transform risks\n    risks, severity_counts = transform_risk_inventory_for_html(\n        risk_data, risk_definitions, resource_inventory_dict\n    )\n\n    # Transform costs\n    months, cost_values, total_cost, currency, currency_symbol = (\n        transform_cost_inventory_for_html(cost_data)\n    )\n\n    # Transform resource data with names and icons\n    resource_counts = []\n    for resource_type, resource in resource_inventory_dict.items():\n        count = resource.get(\"count\", 0)\n        resource_info = resource_type_mapping.get(str(resource_type), {})\n        name = resource_info.get(\"name\", \"Unknown Resource\")\n        icon = resource_info.get(\"icon\", \"assets/icons/default.png\").lstrip(\"/\")\n\n        resource_counts.append(\n            {\"resource_type\": resource_type, \"name\": name, \"icon\": icon, \"count\": count}\n        )\n\n    # Calculate total resources\n    total_resources = sum(item[\"count\"] for item in resource_counts)\n\n    # Transform alternative technologies\n    alternative_technologies_data = transform_alt_tech_for_html(\n        resource_inventory, alternatives, alternative_technologies, exit_strategy\n    )\n\n    # Scoring Data\n    scoring_context = {\n        \"scoring_data\": bool(scoring_data),\n        \"exit_score\": scoring_data.get(\"exit_score\", 0) if scoring_data else 0,\n        \"human\": scoring_data.get(\"human_score\", 0) if scoring_data else 0,\n        \"technology\": scoring_data.get(\"technology_score\", 0) if scoring_data else 0,\n        \"operational\": scoring_data.get(\"operational_score\", 0) if scoring_data else 0,\n    }\n\n    # Render the HTML template\n    template_path = os.path.join(\"assets\", \"template\", \"index.html\")\n    with open(template_path, \"r\") as file:\n        template_content = file.read()\n\n    template = Template(template_content)\n    html_content = template.render(\n        **metadata,\n        **scoring_context,\n        risks=risks,\n        high_risk_count=severity_counts[\"high\"],\n        medium_risk_count=severity_counts[\"medium\"],\n        low_risk_count=severity_counts[\"low\"],\n        total_cost=total_cost,\n        months_json=json.dumps(months),\n        costs_json=json.dumps(cost_values),\n        currency_symbol=currency_symbol,\n        total_resources=total_resources,\n        resource_inventory=resource_counts,\n        alternative_technologies=alternative_technologies_data,\n    )\n\n    # Save HTML report\n    html_path = os.path.join(report_path, \"index.html\")\n    with open(html_path, \"w\") as report_file:\n        report_file.write(html_content)\n\n    return html_path\n\n\ndef generate_json_report(\n    raw_data_path: str,\n    metadata: Dict[str, Any],\n    resource_type_mapping: Dict[str, Dict[str, Any]],\n    resource_inventory: List[Dict[str, Any]],\n    cost_data: List[Dict[str, Any]],\n    scoring_data: Optional[Dict[str, Any]],\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> str:\n    # Transform data for JSON\n    transformed_resource_inventory = transform_resource_inventory_for_json(\n        resource_inventory, resource_type_mapping\n    )\n    transformed_cost_inventory = transform_cost_inventory_for_json(cost_data)\n    transformed_risk_inventory = transform_risk_inventory_for_json(\n        risk_data, risk_definitions, resource_inventory\n    )\n    transformed_alt_tech = transform_alt_tech_for_json(\n        resource_inventory, alternatives, alternative_technologies, exit_strategy\n    )\n\n    # Build the JSON structure\n    report_json = {\n        \"meta\": metadata,\n        \"data\": {\n            \"resource_inventory\": transformed_resource_inventory,\n            \"cost_inventory\": transformed_cost_inventory,\n            \"risk_inventory\": transformed_risk_inventory,\n        },\n    }\n\n    # Add scoring_data only if present\n    if scoring_data:\n        report_json[\"data\"][\"scoring_data\"] = {\n            \"exit_score\": scoring_data.get(\"exit_score\", 0),\n            \"human_score\": scoring_data.get(\"human_score\", 0),\n            \"technology_score\": scoring_data.get(\"technology_score\", 0),\n            \"operational_score\": scoring_data.get(\"operational_score\", 0),\n        }\n\n    # Add alternative technologies\n    report_json[\"data\"][\"alternative_technologies\"] = transformed_alt_tech\n\n    # Save JSON to file\n    json_path = os.path.join(raw_data_path, \"assessment_result.json\")\n    with open(json_path, \"w\") as json_file:\n        json.dump(report_json, json_file, indent=4)\n\n    return json_path\n\n\ndef generate_pdf_report(\n    provider_details: Dict[str, Any],\n    report_path: str,\n    metadata: Dict[str, Any],\n    resource_type_mapping: Dict[str, Any],\n    resource_inventory: List[Dict[str, Any]],\n    cost_data: List[Dict[str, Any]],\n    scoring_data: Optional[Dict[str, Any]],\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> str:\n    # Define the PDF path\n    pdf_path = os.path.join(report_path, \"report.pdf\")\n\n    # Define a template for the header and footer\n    def header_footer(canvas, doc):\n        # Make sure draw_header_footer is defined and accessible\n        draw_header_footer(report_path, canvas, doc)\n\n    # Create a document template with the header and footer\n    doc = SimpleDocTemplate(\n        pdf_path, pagesize=A4, title=\"EscapeCloud_-_Cloud_Exit_Assessment\"\n    )\n    styles = getSampleStyleSheet()\n    content_style = ParagraphStyle(\n        \"ContentStyle\", fontSize=10, leading=12, spaceAfter=10\n    )\n    styles[\"Heading1\"].leading = 1.5 * styles[\"Heading1\"].fontSize\n    styles[\"Heading1\"].textColor = HexColor(\"#112726\")\n    styles[\"Heading2\"].leading = 1.5 * styles[\"Heading2\"].fontSize\n    styles[\"Heading2\"].textColor = HexColor(\"#112726\")\n    tablecontent_style = styles[\"BodyText\"]\n\n    # Define a custom padding value\n    header_padding = 12\n\n    content = []\n\n    # --- # Page 1: Summary ---\n    content.append(Spacer(1, header_padding))\n    content.append(Paragraph(\"Summary\", styles[\"Heading1\"]))\n    summary_block1 = \"Quick overview of the assessment:\"\n    content.append(Paragraph(summary_block1, content_style))\n\n    # Prepare mappings\n    cloud_service_provider_map = {\n        \"1\": \"Microsoft Azure\",\n        \"2\": \"Amazon Web Services\",\n        \"3\": \"Alibaba Cloud\",\n        \"4\": \"Google Cloud\",\n    }\n\n    exit_strategy_map = {\n        \"1\": \"Repatriation to On-Premises\",\n        \"2\": \"Hybrid Cloud Adoption\",\n        \"3\": \"Migration to Alternate Cloud\",\n    }\n\n    type_map = {\"1\": \"Basic\", \"2\": \"Standard\"}\n\n    # Prepare the summary data\n    summary_data = [\n        [\"Name\", \"Value\"],\n        [\n            \"Cloud Service Provider\",\n            cloud_service_provider_map.get(\n                str(metadata[\"cloud_service_provider\"]), \"Unknown\"\n            ),\n        ],\n        [\n            \"Exit Strategy\",\n            exit_strategy_map.get(str(metadata[\"exit_strategy\"]), \"Unknown\"),\n        ],\n        [\"Assessment Type\", type_map.get(str(metadata[\"assessment_type\"]), \"Unknown\")],\n        [\"TimeStamp\", metadata[\"timestamp\"]],\n    ]\n\n    # Column widths\n    summary_colWidths = [4 * cm, 11.5 * cm]\n\n    # Create the summary table\n    summary_table = Table(summary_data, colWidths=summary_colWidths)\n\n    # Define the summary table style\n    summary_table_style = TableStyle(\n        [\n            (\n                \"BACKGROUND\",\n                (0, 0),\n                (-1, 0),\n                HexColor(\"#115e59\"),\n            ),  # Header row background color\n            (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),  # Header row text color\n            (\"GRID\", (0, 0), (-1, -1), 1, HexColor(\"#000000\")),  # Grid lines\n            (\"ALIGN\", (0, 0), (-1, -1), \"LEFT\"),  # Left align all cells\n            (\n                \"VALIGN\",\n                (0, 0),\n                (-1, -1),\n                \"MIDDLE\",\n            ),  # Middle vertical alignment for all cells\n            (\"FONTNAME\", (0, 0), (-1, 0), \"Helvetica-Bold\"),  # Bold font for header row\n            (\"FONTSIZE\", (0, 0), (-1, 0), 11),  # Font size for header row\n            (\"BOTTOMPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n            (\"TOPPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n        ]\n    )\n\n    summary_table.setStyle(summary_table_style)\n\n    # Add summary to content\n    content.append(summary_table)\n    content.append(Spacer(1, 12))\n\n    # --- Page 1: Scope of Assessment ---\n    content.append(Paragraph(\"Scope of Assessment\", styles[\"Heading2\"]))\n    scope_block1 = \"Defined scope of assessment:\"\n    content.append(Paragraph(scope_block1, content_style))\n\n    # Prepare the scope data\n    scope_data = [[\"Name\", \"Value\"]]\n\n    if metadata[\"cloud_service_provider\"] == 1:  # Azure\n        scope_data.extend(\n            [\n                [\"Tenant ID\", provider_details.get(\"tenantId\", \"N/A\")],\n                [\"Client ID\", provider_details.get(\"clientId\", \"N/A\")],\n                [\n                    \"Client Secret\",\n                    anonymize_string(provider_details.get(\"clientSecret\", \"N/A\")),\n                ],\n                [\"Subscription ID\", provider_details.get(\"subscriptionId\", \"N/A\")],\n                [\n                    \"Resource Group Name\",\n                    provider_details.get(\"resourceGroupName\", \"N/A\"),\n                ],\n            ]\n        )\n    elif metadata[\"cloud_service_provider\"] == 2:  # AWS\n        scope_data.extend(\n            [\n                [\"Access Key\", provider_details.get(\"accessKey\", \"N/A\")],\n                [\n                    \"Secret Key\",\n                    anonymize_string(provider_details.get(\"secretKey\", \"N/A\")),\n                ],\n                [\"Region\", provider_details.get(\"region\", \"N/A\")],\n            ]\n        )\n    else:\n        scope_data.append([\"N/A\", \"N/A\"])\n\n    # Column widths\n    scope_colWidths = [4 * cm, 11.5 * cm]\n\n    # Create the scope table\n    scope_table = Table(scope_data, colWidths=scope_colWidths)\n\n    # Define the scope table style\n    scope_table_style = TableStyle(\n        [\n            (\n                \"BACKGROUND\",\n                (0, 0),\n                (-1, 0),\n                HexColor(\"#115e59\"),\n            ),  # Header row background color\n            (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),  # Header row text color\n            (\"GRID\", (0, 0), (-1, -1), 1, HexColor(\"#000000\")),  # Grid lines\n            (\"ALIGN\", (0, 0), (-1, -1), \"LEFT\"),  # Left align all cells\n            (\n                \"VALIGN\",\n                (0, 0),\n                (-1, -1),\n                \"MIDDLE\",\n            ),  # Middle vertical alignment for all cells\n            (\"FONTNAME\", (0, 0), (-1, 0), \"Helvetica-Bold\"),  # Bold font for header row\n            (\"FONTSIZE\", (0, 0), (-1, 0), 11),  # Font size for header row\n            (\"BOTTOMPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n            (\"TOPPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n        ]\n    )\n\n    scope_table.setStyle(scope_table_style)\n\n    # Add scope to content\n    content.append(scope_table)\n    content.append(Spacer(1, 12))\n\n    # --- # Page 1: Costs ---\n    content.append(Paragraph(\"Costs\", styles[\"Heading2\"]))\n    # costs_block1 = \"Overview of the costs for the last 6 months:\"\n    # content.append(Paragraph(costs_block1, content_style))\n    costs_block2 = \"Examining the costs reveals the financial impact of the transition, allowing for more informed decision-making and strategic planning.\"\n    costs_paragraph = Paragraph(costs_block2, tablecontent_style)\n\n    # Transform the cost data for the PDF\n    months, costs, currency_symbol = transform_cost_inventory_for_pdf(cost_data)\n\n    # Draw the cost chart\n    cost_chart = draw_cost_chart(months, costs)\n\n    # Create the data structure for the table\n    costcharts_table_data = [\n        [costs_paragraph, \"\", \"\", cost_chart, \"\", \"\"],  # Row 1: Paragraph and Chart\n        months,  # Row 2: Months\n        [f\"{currency_symbol} {cost:.2f}\" for cost in costs],  # Row 3: Costs\n    ]\n\n    # Create the table with 6 columns\n    costcharts_table = Table(\n        costcharts_table_data, colWidths=[2.58333333333 * cm] * 6  # Equal width columns\n    )\n\n    # Define the table style\n    costcharts_table_style = TableStyle(\n        [\n            # Merge cells for Row 1\n            (\"SPAN\", (0, 0), (2, 0)),  # Merge columns 1, 2, and 3 for the paragraph\n            (\"SPAN\", (3, 0), (5, 0)),  # Merge columns 4, 5, and 6 for the chart\n            # Align the merged cell (Row 1, Column 1-2-3) to top-left\n            (\"VALIGN\", (0, 0), (2, 0), \"TOP\"),  # Align vertically to top\n            (\"ALIGN\", (0, 0), (2, 0), \"LEFT\"),  # Align horizontally to left\n            # Remove padding for the merged cell in Row 1, Columns 1-2-3\n            (\"LEFTPADDING\", (0, 0), (2, 0), 0),\n            (\"RIGHTPADDING\", (0, 0), (2, 0), 0),\n            (\"TOPPADDING\", (0, 0), (2, 0), 0),\n            (\"BOTTOMPADDING\", (0, 0), (2, 0), 0),\n            # Background and text color for Row 2 (months)\n            (\n                \"BACKGROUND\",\n                (0, 1),\n                (-1, 1),\n                HexColor(\"#115e59\"),\n            ),  # Row 2 background color\n            (\"TEXTCOLOR\", (0, 1), (-1, 1), colors.white),  # Row 2 text color\n            (\"FONTNAME\", (0, 1), (-1, 1), \"Helvetica-Bold\"),  # Bold font for Row 2\n            # Center alignment for Row 2 (months)\n            (\"ALIGN\", (0, 1), (-1, 1), \"CENTER\"),  # Center align -> Row 2 text\n            # Font and alignment for Row 3 (costs)\n            (\"FONTNAME\", (0, 2), (-1, 2), \"Helvetica\"),  # Regular font for Row 3\n            (\"ALIGN\", (0, 2), (-1, 2), \"CENTER\"),  # Center align -> Row 3 text\n            # Grid lines for Row 2 and Row 3\n            (\n                \"GRID\",\n                (0, 1),\n                (-1, 2),\n                1,\n                colors.black,\n            ),  # Grid for months and costs rows\n            # Center alignment and vertical alignment for all cells\n            (\"VALIGN\", (0, 0), (-1, -1), \"MIDDLE\"),  # Vertical alignment for all cells\n            (\n                \"VALIGN\",\n                (0, 0),\n                (2, 0),\n                \"TOP\",\n            ),  # Align vertically to top for the merged cell\n        ]\n    )\n\n    # Apply the table style\n    costcharts_table.setStyle(costcharts_table_style)\n\n    # Add the table to your content\n    content.append(costcharts_table)\n    content.append(PageBreak())\n\n    # Page 2: Risks\n    content.append(Spacer(1, header_padding))\n    content.append(Paragraph(\"Risk Assessment\", styles[\"Heading1\"]))\n    risk_block1 = \"The Risk Assessment provides a thorough evaluation of potential risks associated with the cloud resources utilized in the project and the alternative technologies available in the market:\"\n    content.append(Paragraph(risk_block1, content_style))\n    content.append(Spacer(1, 12))\n\n    # Transform the risk data for the PDF and get severity counts\n    risks, severity_counts = transform_risk_inventory_for_pdf(\n        risk_data, risk_definitions, resource_inventory\n    )\n\n    # severity_counts is a dict like: {'high': X, 'medium': Y, 'low': Z}\n    risk_chart_data = {\n        \"high\": severity_counts[\"high\"],\n        \"medium\": severity_counts[\"medium\"],\n        \"low\": severity_counts[\"low\"],\n    }\n    risk_chart = draw_risk_chart(risk_chart_data)\n    content.append(risk_chart)\n    content.append(Spacer(1, 12))\n\n    # Sort risks by severity\n    severity_order = {\"high\": 1, \"medium\": 2, \"low\": 3}\n    risks.sort(key=lambda r: severity_order[r[\"severity\"]])\n\n    # Define the path to severity icons\n    severity_icon_map = {\n        \"high\": (os.path.join(report_path, \"assets/icons/severity/high.png\"), 22.5, 12),\n        \"medium\": (\n            os.path.join(report_path, \"assets/icons/severity/medium.png\"),\n            39,\n            12,\n        ),\n        \"low\": (os.path.join(report_path, \"assets/icons/severity/low.png\"), 20.5, 12),\n    }\n\n    # Build the risk table data\n    risk_table_data = [[\"#\", \"Risk name\", \"Impacted\", \"Severity\"]]\n    for i, risk in enumerate(risks):\n        impacted_str = (\n            str(risk[\"impacted_resources_count\"])\n            if risk[\"impacted_resources_count\"] > 0\n            else \"-\"\n        )\n\n        # Get the severity level and corresponding icon details\n        severity_level = risk[\"severity\"].lower()\n        icon_details = severity_icon_map.get(severity_level, None)\n\n        if icon_details:\n            icon_path, icon_width, icon_height = icon_details\n            if os.path.exists(icon_path):\n                severity_icon = Image(icon_path, width=icon_width, height=icon_height)\n            else:\n                severity_icon = Paragraph(\"N/A\", tablecontent_style)\n        else:\n            severity_icon = Paragraph(\"N/A\", tablecontent_style)\n\n        risk_table_data.append([str(i + 1), risk[\"name\"], impacted_str, severity_icon])\n\n    # Add the total risks row\n    total_risks = len(risks)\n    risk_table_data.append([\"Total Risks\", \"\", \"\", str(total_risks)])\n\n    # Define column widths for the risk table\n    risk_table_colWidths = [0.5 * cm, 10 * cm, 3 * cm, 2 * cm]\n    risk_table = Table(risk_table_data, colWidths=risk_table_colWidths)\n\n    risk_table_style_commands = [\n        (\"BACKGROUND\", (0, 0), (-1, 0), HexColor(\"#115e59\")),  # Header row background\n        (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),  # Header text color\n        (\"BACKGROUND\", (0, -1), (-1, -1), HexColor(\"#115e59\")),  # Last row background\n        (\"TEXTCOLOR\", (0, -1), (-1, -1), colors.white),  # Last row text color\n        (\"BOX\", (0, 0), (-1, -1), 1, HexColor(\"#112726\")),\n        (\"BOTTOMPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n        (\"TOPPADDING\", (0, 0), (-1, 0), 12),\n        # Remove SPAN if not needed\n        # ('SPAN', (-4, -1), (-2, -1)),\n        (\"ALIGN\", (0, 1), (0, -2), \"LEFT\"),\n        (\"VALIGN\", (0, 1), (0, -2), \"MIDDLE\"),\n        (\"ALIGN\", (1, 1), (1, -2), \"LEFT\"),\n        (\"VALIGN\", (1, 1), (1, -2), \"MIDDLE\"),\n        (\"ALIGN\", (2, 1), (2, -2), \"CENTER\"),\n        (\"VALIGN\", (2, 1), (2, -2), \"MIDDLE\"),\n        (\"ALIGN\", (3, 1), (3, -2), \"CENTER\"),\n        (\"VALIGN\", (3, 1), (3, -2), \"MIDDLE\"),\n        (\"ALIGN\", (-1, 0), (-1, 0), \"CENTER\"),\n        (\"VALIGN\", (-1, 0), (-1, 0), \"MIDDLE\"),\n        (\"ALIGN\", (-1, -1), (-1, -1), \"CENTER\"),\n        (\"VALIGN\", (-1, -1), (-1, -1), \"MIDDLE\"),\n    ]\n\n    risk_table.setStyle(TableStyle(risk_table_style_commands))\n    content.append(risk_table)\n    content.append(PageBreak())\n\n    # Page 3: EscapeCloud Scoring\n    if metadata.get(\"assessment_type\") == 2:\n        content.append(Spacer(1, header_padding))\n        content.append(Paragraph(\"EscapeCloud Scoring\", styles[\"Heading1\"]))\n        content.append(Paragraph(\"Scoring #1 - Exit Score\", styles[\"Heading2\"]))\n\n        scoring_block1 = \"The following gauge chart visualizes a combined score that reflects both risk assessment results and the evaluation of alternative technologies:\"\n\n        content.append(Paragraph(scoring_block1, content_style))\n        content.append(Spacer(1, 12))\n        exit_score = scoring_data.get(\"exit_score\", 0) if scoring_data else 0\n\n        # Define output path for charts\n        chart_output_path = os.path.join(report_path, \"assets/charts\")\n        os.makedirs(chart_output_path, exist_ok=True)\n\n        exit_score_image_path = draw_exitscore_chart(\n            exit_score, chart_output_path, width=750, height=500\n        )\n\n        # Define the table data\n        exitscore_table_data = [\n            [\"\", \"\"],\n            [\"Complex (0 - 20)\", \"\"],\n            [\"Challenging (20 - 40)\", \"\"],\n            [\"Manageable (40 - 60)\", \"\"],\n            [\"Smooth Transition (60 - 80)\", \"\"],\n            [\"Seamless (80 - 100)\", \"\"],\n        ]\n\n        exitscore_table_data[1][1] = Image(\n            exit_score_image_path, width=7.5 * cm, height=5 * cm\n        )\n\n        # Column widhts\n        exitscore_colWidths = [5 * cm, 10.5 * cm]\n\n        # Create the table\n        exitscore_table = Table(exitscore_table_data, colWidths=exitscore_colWidths)\n\n        # Style the table\n        exitscore_table_style = TableStyle(\n            [\n                (\"SPAN\", (0, 0), (1, 0)),\n                (\"BACKGROUND\", (0, 0), (1, 0), HexColor(\"#115e59\")),\n                (\"TEXTCOLOR\", (0, 0), (1, 0), colors.white),\n                (\"FONTNAME\", (0, 0), (1, 0), \"Helvetica-Bold\"),\n                (\"ALIGN\", (0, 0), (1, 0), \"CENTER\"),\n                (\"VALIGN\", (0, 0), (1, 0), \"MIDDLE\"),\n                (\"SPAN\", (1, 1), (1, 5)),\n                (\"GRID\", (0, 0), (-1, -1), 1, colors.black),\n                (\"ALIGN\", (0, 1), (0, 5), \"LEFT\"),\n                (\"VALIGN\", (0, 1), (0, 5), \"MIDDLE\"),\n                (\"ALIGN\", (1, 1), (1, 1), \"CENTER\"),\n                (\"VALIGN\", (1, 1), (1, 1), \"MIDDLE\"),\n            ]\n        )\n        exitscore_table.setStyle(exitscore_table_style)\n        content.append(exitscore_table)\n        content.append(Spacer(1, 12))\n\n        content.append(\n            Paragraph(\"Scoring #2 - Vendor Lock-In Score\", styles[\"Heading2\"])\n        )\n        scoring_block2 = \"The following radar chart visualizes the assessment of alternative technologies across three dimensions: Human (skills availability), Technology (maturity and vendor stability), and Operational (ecosystem and support services) — only where viable alternatives exist:\"\n        content.append(Paragraph(scoring_block2, content_style))\n        content.append(Spacer(1, 12))\n\n        human_score = scoring_data.get(\"human_score\", 0) if scoring_data else 0\n        technology_score = (\n            scoring_data.get(\"technology_score\", 0) if scoring_data else 0\n        )\n        operational_score = (\n            scoring_data.get(\"operational_score\", 0) if scoring_data else 0\n        )\n\n        vendor_lockin_chart = draw_vendor_lockin_radar_chart(\n            human_score, technology_score, operational_score\n        )\n        content.append(vendor_lockin_chart)\n\n        # Define the table data\n        vendor_lockin_table_data = [\n            [\"Human\", \"Technology\", \"Operational\"],\n            [human_score, technology_score, operational_score],\n        ]\n\n        # Column widhts\n        vendor_lockin_colWidths = [5 * cm, 5 * cm, 5 * cm]\n\n        # Create the table\n        vendor_lockin_table = Table(\n            vendor_lockin_table_data, colWidths=vendor_lockin_colWidths\n        )\n\n        # Style the table\n        vendor_lockin_table_style = TableStyle(\n            [\n                (\"BACKGROUND\", (0, 0), (-1, 0), HexColor(\"#115e59\")),\n                (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),\n                (\"ALIGN\", (0, 0), (-1, 0), \"CENTER\"),\n                (\"VALIGN\", (0, 0), (-1, 0), \"MIDDLE\"),\n                (\"ALIGN\", (0, 0), (-1, -1), \"CENTER\"),\n                (\"FONTNAME\", (0, 0), (-1, 0), \"Helvetica-Bold\"),\n                (\"GRID\", (0, 0), (-1, -1), 1, colors.black),\n            ]\n        )\n        vendor_lockin_table.setStyle(vendor_lockin_table_style)\n        content.append(vendor_lockin_table)\n        content.append(PageBreak())\n\n    # Page 4: Resource Inventory\n    content.append(Spacer(1, header_padding))\n    content.append(Paragraph(\"Resource Inventory\", styles[\"Heading1\"]))\n    res_block1 = \"The Resource Inventory provides a summary of the cloud resources provisioned within the defined scope:\"\n    content.append(Paragraph(res_block1, content_style))\n    content.append(Spacer(1, 12))\n\n    # Transform the resource inventory data for the PDF\n    resources = transform_resource_inventory_for_pdf(\n        resource_inventory, resource_type_mapping, report_path\n    )\n\n    # Compute total resources\n    total_resources = sum(res[\"count\"] for res in resources)\n\n    # Build the table data\n    resource_data = [[\"#\", \"Resource type\", \"\", \"No.\"]]\n\n    for res in resources:\n        resource_data.append(\n            [\n                str(res[\"id\"]),\n                res[\"resource_name\"],\n                Image(res[\"icon_url\"], width=20, height=20),\n                str(res[\"count\"]),\n            ]\n        )\n\n    # Add the total resources row\n    resource_data.append([\"Total Resources\", \"\", \"\", str(total_resources)])\n\n    res_colWidths = [1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm]\n    res_table = Table(resource_data, colWidths=res_colWidths)\n\n    res_table_style_commands = [\n        (\n            \"BACKGROUND\",\n            (0, 0),\n            (-1, 0),\n            HexColor(\"#115e59\"),\n        ),  # Header row background color\n        (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),  # Header row text color\n        (\n            \"BACKGROUND\",\n            (0, -1),\n            (-1, -1),\n            HexColor(\"#115e59\"),\n        ),  # Last row background color\n        (\"TEXTCOLOR\", (0, -1), (-1, -1), colors.white),  # Last row text color\n        (\"BOX\", (0, 0), (-1, -1), 1, HexColor(\"#112726\")),\n        (\"BOTTOMPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n        (\"TOPPADDING\", (0, 0), (-1, 0), 12),  # Padding for header row\n        # If you previously had a SPAN on the last row, remove if not needed now.\n        # ('SPAN', (-4, -1), (-2, -1)), # remove if not required\n        (\"ALIGN\", (0, 1), (0, -2), \"LEFT\"),  # Aligning the '#' column\n        (\"VALIGN\", (0, 1), (0, -2), \"MIDDLE\"),\n        (\"ALIGN\", (1, 1), (1, -2), \"LEFT\"),  # Resource name column\n        (\"VALIGN\", (1, 1), (1, -2), \"MIDDLE\"),\n        (\"ALIGN\", (2, 1), (2, -2), \"CENTER\"),  # Icon column\n        (\"VALIGN\", (2, 1), (2, -2), \"MIDDLE\"),\n        (\"ALIGN\", (3, 1), (3, -2), \"CENTER\"),  # Number column\n        (\"VALIGN\", (3, 1), (3, -2), \"MIDDLE\"),\n        (\"ALIGN\", (-1, 0), (-1, 0), \"CENTER\"),\n        (\"VALIGN\", (-1, 0), (-1, 0), \"MIDDLE\"),\n        (\"ALIGN\", (-1, -1), (-1, -1), \"CENTER\"),\n        (\"VALIGN\", (-1, -1), (-1, -1), \"MIDDLE\"),\n    ]\n\n    res_table_style = TableStyle(res_table_style_commands)\n    res_table.setStyle(res_table_style)\n\n    content.append(res_table)\n    content.append(PageBreak())\n\n    # Page 5: Alternative Technologies\n    content.append(Spacer(1, header_padding))\n    content.append(Paragraph(\"Alternative Technologies\", styles[\"Heading1\"]))\n\n    alttech_block = (\n        \"The Alternative Technology provides a summary of the alternative technology landscape \"\n        \"for each identified resource in the Resource Inventory, based on our dataset and market research. \"\n        \"It also includes a count of the available alternative technologies for each resource:\"\n    )\n    content.append(Paragraph(alttech_block, content_style))\n    content.append(Spacer(1, 12))\n\n    # Transform the alternative technologies data for the PDF\n    alttech = transform_alt_tech_for_pdf(\n        resource_inventory,\n        resource_type_mapping,\n        alternatives,\n        alternative_technologies,\n        exit_strategy,\n        report_path,\n    )\n\n    # Build the table data\n    alttech_data = [[\"#\", \"Resource type\", \"\", \"No.\"]]\n    for res in alttech:\n        alttech_data.append(\n            [\n                str(res[\"id\"]),\n                res[\"resource_name\"],\n                Image(res[\"icon_url\"], width=20, height=20) if res[\"icon_url\"] else \"\",\n                str(res[\"count\"]),\n            ]\n        )\n\n    # Define the column widths\n    alttech_colWidths = [1 * cm, 11.5 * cm, 1.5 * cm, 1.5 * cm]\n\n    # Create and style the alternative technology table\n    alttech_table = Table(alttech_data, colWidths=alttech_colWidths)\n    alttech_table_style_commands = [\n        (\n            \"BACKGROUND\",\n            (0, 0),\n            (-1, 0),\n            HexColor(\"#115e59\"),\n        ),  # Header row background color\n        (\"TEXTCOLOR\", (0, 0), (-1, 0), colors.white),  # Header row text color\n        (\"BOX\", (0, 0), (-1, -1), 1, HexColor(\"#000000\")),  # Draw box around the table\n        (\n            \"BOTTOMPADDING\",\n            (0, 1),\n            (-1, -1),\n            6,\n        ),  # Apply bottom padding to all rows except the header\n        (\n            \"TOPPADDING\",\n            (0, 1),\n            (-1, -1),\n            6,\n        ),  # Apply top padding to all rows except the header\n        (\n            \"ALIGN\",\n            (2, 0),\n            (2, -1),\n            \"CENTER\",\n        ),  # Center align the text in the icon column\n        (\"VALIGN\", (2, 0), (2, -1), \"MIDDLE\"),\n        (\"ALIGN\", (0, 1), (0, -1), \"LEFT\"),\n        (\"VALIGN\", (0, 1), (0, -1), \"MIDDLE\"),\n        (\"ALIGN\", (1, 1), (1, -1), \"LEFT\"),\n        (\"VALIGN\", (1, 1), (1, -1), \"MIDDLE\"),\n        (\"ALIGN\", (2, 1), (2, -1), \"CENTER\"),\n        (\"VALIGN\", (2, 1), (2, -1), \"MIDDLE\"),\n        (\"ALIGN\", (3, 1), (3, -1), \"CENTER\"),\n        (\"VALIGN\", (3, 1), (3, -1), \"MIDDLE\"),\n        (\"ALIGN\", (-1, 0), (-1, 0), \"CENTER\"),  # Center align the \"No.\" header\n        (\"VALIGN\", (-1, 0), (-1, 0), \"MIDDLE\"),\n    ]\n\n    alttech_table.setStyle(TableStyle(alttech_table_style_commands))\n\n    content.append(alttech_table)\n    content.append(PageBreak())\n\n    # Build the PDF document\n    logger.debug(\"Building the PDF document...\")\n    doc.build(content, onFirstPage=header_footer, onLaterPages=header_footer)\n\n    # Return the path of the generated PDF\n    return pdf_path\n"
  },
  {
    "path": "core/utils_report_common.py",
    "content": "from collections import defaultdict\nfrom datetime import datetime\nfrom typing import Any, Dict, List, Optional, Tuple\n\nCURRENCY_SYMBOLS = {\n    \"USD\": \"$\",\n    \"GBP\": \"£\",\n    \"EUR\": \"€\",\n}\n\n\ndef sort_cost_data(cost_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n    return sorted(cost_data, key=lambda x: datetime.strptime(x[\"month\"], \"%Y-%m-%d\"))\n\n\ndef summarize_costs(\n    cost_data: List[Dict[str, Any]], *, last_n: Optional[int] = None\n) -> Tuple[List[str], List[float], float, str, str]:\n    sorted_costs = sort_cost_data(cost_data)\n    if last_n is not None:\n        sorted_costs = sorted_costs[-last_n:]\n\n    months = [\n        datetime.strptime(item[\"month\"], \"%Y-%m-%d\").strftime(\"%b\")\n        for item in sorted_costs\n    ]\n    values = [item[\"cost\"] for item in sorted_costs]\n    total_cost = round(sum(values), 2)\n\n    if sorted_costs:\n        currency_code = sorted_costs[0].get(\"currency\", \"USD\")\n    else:\n        currency_code = \"USD\"\n    currency_symbol = CURRENCY_SYMBOLS.get(currency_code, currency_code)\n\n    return months, values, total_cost, currency_code, currency_symbol\n\n\ndef summarize_risks(\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    *,\n    resource_name_map: Optional[Dict[str, str]] = None,\n    resource_id_map: Optional[Dict[str, int]] = None,\n) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:\n    risk_def_map = {rd[\"id\"]: rd for rd in risk_definitions}\n    severity_counts = {\"high\": 0, \"medium\": 0, \"low\": 0}\n\n    grouped_risks = defaultdict(\n        lambda: {\n            \"impacted_resource_types\": set(),\n            \"impacted_resources_count\": 0,\n            \"has_overall_risk\": False,\n        }\n    )\n\n    for entry in risk_data:\n        risk_code = entry[\"risk\"]\n        resource_type = entry[\"resource_type\"]\n\n        if resource_type is None or resource_type == \"null\":\n            grouped_risks[risk_code][\"has_overall_risk\"] = True\n            continue\n\n        resource_type = str(resource_type)\n        grouped_risks[risk_code][\"impacted_resource_types\"].add(resource_type)\n        grouped_risks[risk_code][\"impacted_resources_count\"] += 1\n\n    summarized_risks = []\n    for risk_code, risk_info in grouped_risks.items():\n        risk_definition = risk_def_map.get(risk_code)\n        if not risk_definition:\n            continue\n\n        severity = risk_definition[\"severity\"]\n        if severity in severity_counts:\n            severity_counts[severity] += 1\n\n        resource_types = sorted(risk_info[\"impacted_resource_types\"])\n        resource_names = None\n        if resource_name_map is not None:\n            resource_names = [\n                resource_name_map.get(resource_type, \"Unknown Resource\")\n                for resource_type in resource_types\n            ]\n\n        resource_ids = None\n        if resource_id_map is not None:\n            resource_ids = [\n                resource_id_map[resource_type]\n                for resource_type in resource_types\n                if resource_type in resource_id_map\n            ]\n\n        impacted_resources_count = (\n            None\n            if risk_info[\"has_overall_risk\"]\n            else risk_info[\"impacted_resources_count\"]\n        )\n\n        summarized_risks.append(\n            {\n                \"id\": risk_code,\n                \"name\": risk_definition[\"name\"],\n                \"description\": risk_definition[\"description\"],\n                \"severity\": severity,\n                \"impacted_resource_types\": resource_types,\n                \"impacted_resources\": resource_names,\n                \"impacted_resource_ids\": resource_ids,\n                \"impacted_resources_count\": impacted_resources_count,\n            }\n        )\n\n    return summarized_risks, severity_counts\n\n\ndef summarize_alternative_technologies(\n    resource_inventory: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> Dict[str, List[Dict[str, Any]]]:\n    active_technologies = {\n        tech[\"id\"]: tech\n        for tech in alternative_technologies\n        if tech.get(\"status\") == \"t\"\n    }\n\n    grouped_alt_tech: Dict[str, List[Dict[str, Any]]] = {\n        str(resource[\"resource_type\"]): [] for resource in resource_inventory\n    }\n\n    for alt in alternatives:\n        if str(alt[\"strategy_type\"]) != str(exit_strategy):\n            continue\n\n        resource_type = str(alt[\"resource_type\"])\n        tech = active_technologies.get(alt[\"alternative_technology\"])\n        if not tech or resource_type not in grouped_alt_tech:\n            continue\n\n        grouped_alt_tech[resource_type].append(\n            {\n                \"product_name\": tech[\"product_name\"],\n                \"product_description\": tech[\"product_description\"],\n                \"product_url\": tech[\"product_url\"],\n                \"open_source\": tech[\"open_source\"] == \"t\",\n                \"support_plan\": tech[\"support_plan\"] == \"t\",\n                \"status\": tech[\"status\"] == \"t\",\n            }\n        )\n\n    return grouped_alt_tech\n\n\ndef enrich_resource_inventory(\n    resource_inventory: List[Dict[str, Any]],\n    resource_type_mapping: Dict[str, Dict[str, Any]],\n    *,\n    report_path: Optional[str] = None,\n) -> List[Dict[str, Any]]:\n    enriched_resources = []\n    for idx, resource in enumerate(resource_inventory):\n        resource_type = str(resource[\"resource_type\"])\n        resource_info = resource_type_mapping.get(resource_type, {})\n        icon = resource_info.get(\"icon\", \"/icons/default.png\")\n\n        entry = {\n            \"id\": idx + 1,\n            \"resource_type\": resource_type,\n            \"code\": resource_info.get(\"code\", \"N/A\"),\n            \"resource_name\": resource_info.get(\"name\", \"Unknown Resource\"),\n            \"icon\": icon,\n            \"location\": resource.get(\"location\", \"Unknown\"),\n            \"count\": resource.get(\"count\", 0),\n        }\n\n        if report_path is not None:\n            entry[\"icon_url\"] = f\"{report_path}/assets{icon}\"\n\n        enriched_resources.append(entry)\n\n    return enriched_resources\n"
  },
  {
    "path": "core/utils_report_html.py",
    "content": "# core/utils_report_html.py\nimport logging\nfrom typing import List, Dict, Any, Tuple\n\nfrom core.utils_report_common import (\n    summarize_alternative_technologies,\n    summarize_costs,\n    summarize_risks,\n)\n\n# Configure logger\nlogger = logging.getLogger(\"core.engine.report_html\")\nlogger.setLevel(logging.INFO)\n\n\ndef transform_cost_inventory_for_html(\n    cost_data: List[Dict[str, Any]],\n) -> Tuple[List[str], List[float], float, str, str]:\n    return summarize_costs(cost_data)\n\n\ndef transform_risk_inventory_for_html(\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    resource_inventory: Dict[str, Dict[str, Any]],\n) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:\n    severity_order = {\"high\": 1, \"medium\": 2, \"low\": 3}\n    resource_name_map = {\n        str(key): value[\"name\"] for key, value in resource_inventory.items()\n    }\n    risks, severity_counts = summarize_risks(\n        risk_data,\n        risk_definitions,\n        resource_name_map=resource_name_map,\n    )\n    risks.sort(key=lambda x: severity_order.get(x[\"severity\"], 4))\n    return risks, severity_counts\n\n\ndef transform_alt_tech_for_html(\n    resource_inventory: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> List[Dict[str, Any]]:\n    alt_tech_data = []\n    grouped_alt_tech = summarize_alternative_technologies(\n        resource_inventory,\n        alternatives,\n        alternative_technologies,\n        exit_strategy,\n    )\n    for resource in resource_inventory:\n        resource_type = str(resource.get(\"resource_type\"))\n        for tech in grouped_alt_tech.get(resource_type, []):\n            alt_tech_data.append(\n                {\n                    \"resource_type_id\": resource.get(\"resource_type\"),\n                    **tech,\n                }\n            )\n    return alt_tech_data\n"
  },
  {
    "path": "core/utils_report_json.py",
    "content": "# core/utils_report_json.py\nimport logging\nfrom typing import List, Dict, Any\n\nfrom core.utils_report_common import (\n    enrich_resource_inventory,\n    sort_cost_data,\n    summarize_alternative_technologies,\n    summarize_risks,\n)\n\n# Configure logger\nlogger = logging.getLogger(\"core.engine.report_json\")\nlogger.setLevel(logging.INFO)\n\n\ndef transform_resource_inventory_for_json(\n    resource_inventory: List[Dict[str, Any]],\n    resource_type_mapping: Dict[str, Dict[str, Any]],\n) -> List[Dict[str, Any]]:\n    enriched_resources = enrich_resource_inventory(\n        resource_inventory, resource_type_mapping\n    )\n    return [\n        {\n            \"id\": resource[\"id\"],\n            \"code\": resource[\"code\"],\n            \"resource_name\": resource[\"resource_name\"],\n            \"location\": resource[\"location\"],\n            \"count\": resource[\"count\"],\n        }\n        for resource in enriched_resources\n    ]\n\n\ndef transform_cost_inventory_for_json(\n    cost_data: List[Dict[str, Any]],\n) -> List[Dict[str, Any]]:\n    sorted_cost_data = sort_cost_data(cost_data)\n\n    cost_inventory = [\n        {\n            \"month\": item[\"month\"],\n            \"cost\": round(item[\"cost\"], 2),\n            \"currency\": item[\"currency\"],\n        }\n        for item in sorted_cost_data\n    ]\n    return cost_inventory\n\n\ndef transform_risk_inventory_for_json(\n    risk_data: List[Dict[str, Any]],\n    risk_definitions: List[Dict[str, Any]],\n    resource_inventory: List[Dict[str, Any]],\n) -> List[Dict[str, Any]]:\n    resource_id_map = {\n        str(value[\"resource_type\"]): key + 1\n        for key, value in enumerate(resource_inventory)\n    }\n    risks, _ = summarize_risks(\n        risk_data,\n        risk_definitions,\n        resource_id_map=resource_id_map,\n    )\n    return [\n        {\n            \"id\": risk[\"id\"],\n            \"name\": risk[\"name\"],\n            \"description\": risk[\"description\"],\n            \"severity\": risk[\"severity\"],\n            \"impacted_resources\": risk[\"impacted_resource_ids\"] or [],\n            \"impacted_resources_count\": risk[\"impacted_resources_count\"],\n        }\n        for risk in risks\n    ]\n\n\ndef transform_alt_tech_for_json(\n    resource_inventory: List[Dict[str, Any]],\n    alternatives: List[Dict[str, Any]],\n    alternative_technologies: List[Dict[str, Any]],\n    exit_strategy: int,\n) -> Dict[int, List[Dict[str, Any]]]:\n    resource_id_map = {\n        str(value[\"resource_type\"]): key + 1\n        for key, value in enumerate(resource_inventory)\n    }\n    grouped_alt_tech = summarize_alternative_technologies(\n        resource_inventory,\n        alternatives,\n        alternative_technologies,\n        exit_strategy,\n    )\n    grouped_alt_tech_data = {\n        resource_id: [] for resource_id in resource_id_map.values()\n    }\n    for resource_type, technologies in grouped_alt_tech.items():\n        resource_id = resource_id_map.get(resource_type)\n        if not resource_id:\n            continue\n        grouped_alt_tech_data[resource_id] = [\n            {\"id\": idx + 1, **tech} for idx, tech in enumerate(technologies)\n        ]\n\n    return {\n        key: grouped_alt_tech_data[key] for key in sorted(grouped_alt_tech_data.keys())\n    }\n"
  },
  {
    "path": "core/utils_report_pdf.py",
    "content": "# core/utils_report_pdf.py\nimport os\nimport math\nimport logging\nfrom datetime import datetime\nfrom typing import List, Dict, Any, Tuple\nfrom math import cos, sin, radians\n\n# ReportLab\nfrom reportlab.lib.pagesizes import A4\nfrom reportlab.lib.units import cm\nfrom reportlab.lib.styles import ParagraphStyle\nfrom reportlab.lib import colors\nfrom reportlab.lib.colors import HexColor\nfrom reportlab.platypus import Paragraph, Image, Table, TableStyle\nfrom reportlab.graphics.shapes import Drawing, Polygon, Line, String\nfrom reportlab.graphics.charts.legends import Legend\nfrom reportlab.graphics.charts.piecharts import Pie\nfrom reportlab.graphics.charts.barcharts import VerticalBarChart\n\n# Plotly\nimport plotly.graph_objects as go\n\nfrom core.utils_report_common import (\n    enrich_resource_inventory,\n    summarize_alternative_technologies,\n    summarize_costs,\n    summarize_risks,\n)\n\n# Configure logger\nlogger = logging.getLogger(\"core.engine.report_pdf\")\nlogger.setLevel(logging.INFO)\n\n\ndef transform_resource_inventory_for_pdf(\n    resource_inventory: list, resource_type_mapping: Dict[str, Any], report_path: str\n) -> List[Dict[str, Any]]:\n    enriched_resources = enrich_resource_inventory(\n        resource_inventory,\n        resource_type_mapping,\n        report_path=report_path,\n    )\n    return [\n        {\n            \"id\": resource[\"id\"],\n            \"resource_name\": resource[\"resource_name\"],\n            \"icon_url\": resource[\"icon_url\"],\n            \"location\": resource[\"location\"],\n            \"count\": resource[\"count\"],\n        }\n        for resource in enriched_resources\n    ]\n\n\ndef transform_cost_inventory_for_pdf(\n    cost_data: list,\n) -> Tuple[List[str], List[float], str]:\n    months, costs, _, _, currency_symbol = summarize_costs(cost_data, last_n=6)\n    return months, costs, currency_symbol\n\n\ndef transform_risk_inventory_for_pdf(\n    risk_data: list, risk_definitions: list, resource_inventory: list\n) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:\n    risks, severity_counts = summarize_risks(risk_data, risk_definitions)\n    return [\n        {\n            \"name\": risk[\"name\"],\n            \"severity\": risk[\"severity\"],\n            \"impacted_resources_count\": risk[\"impacted_resources_count\"] or 0,\n        }\n        for risk in risks\n    ], severity_counts\n\n\ndef transform_alt_tech_for_pdf(\n    resource_inventory: list,\n    resource_type_mapping: Dict[str, Any],\n    alternatives: list,\n    alternative_technologies: list,\n    exit_strategy: int,\n    report_path: str,\n) -> List[Dict[str, Any]]:\n    grouped_alt_tech = summarize_alternative_technologies(\n        resource_inventory,\n        alternatives,\n        alternative_technologies,\n        exit_strategy,\n    )\n    alt_tech = []\n    for idx, resource in enumerate(resource_inventory):\n        rtype_str = str(resource[\"resource_type\"])\n        rtype_info = resource_type_mapping.get(rtype_str, {})\n        resource_name = rtype_info.get(\"name\", \"Unknown Resource\")\n\n        icon_path = \"/assets\" + rtype_info.get(\"icon\", \"/icons/default.png\")\n        icon_url = f\"{report_path}{icon_path}\"\n\n        count = len(grouped_alt_tech.get(rtype_str, []))\n\n        alt_tech.append(\n            {\n                \"id\": idx + 1,\n                \"resource_name\": resource_name,\n                \"icon_url\": icon_url,\n                \"count\": count,\n            }\n        )\n\n    return alt_tech\n\n\ndef draw_header_footer(report_path: str, canvas, doc) -> None:\n    # Save the state of the canvas to not affect the drawing\n    canvas.saveState()\n    width, height = A4\n\n    # Include the date in the format mm-dd-yyyy\n    current_date = datetime.now().strftime(\"%m-%d-%Y\")\n    left_text_content1 = \"EscapeCloud Community Edition - Report\"\n    left_text_content2 = f\"Date: {current_date}\"\n\n    # Define the header content with Paragraphs\n    header_style = ParagraphStyle(\n        \"HeaderStyle\", fontSize=10, textColor=HexColor(\"#9cafae\")\n    )\n    header_data = [\n        [Paragraph(left_text_content1, header_style), \"\", \"\"],\n        [Paragraph(left_text_content2, header_style), \"\", \"\"],\n    ]\n\n    # Create the header table\n    table = Table(\n        header_data, colWidths=[width - 188 - doc.rightMargin - doc.leftMargin, 10, 150]\n    )\n\n    # Define the style for the table\n    table.setStyle(\n        TableStyle(\n            [\n                (\"SPAN\", (1, 0), (1, 1)),  # Merge Column 2 in both rows\n                (\"SPAN\", (2, 0), (2, 1)),  # Merge Column 3 in both rows\n                (\n                    \"ALIGN\",\n                    (0, 0),\n                    (0, 0),\n                    \"LEFT\",\n                ),  # Align left_text_content1 to the left\n                (\n                    \"ALIGN\",\n                    (0, 1),\n                    (0, 1),\n                    \"LEFT\",\n                ),  # Align left_text_content2 to the left\n                (\"ALIGN\", (2, 0), (2, 1), \"RIGHT\"),  # Align logo to the right\n                (\"VALIGN\", (0, 0), (0, 1), \"TOP\"),  # Vertically align to the top\n                (\"VALIGN\", (2, 0), (2, 1), \"MIDDLE\"),  # Vertically align to the middle\n                # ('GRID', (0, 0), (-1, -1), 0.5, colors.red),  # Temporary borders for visualization\n            ]\n        )\n    )\n    # Build the header table and draw it on the canvas\n    table.wrapOn(canvas, doc.leftMargin, height - doc.topMargin)\n    table.drawOn(canvas, doc.leftMargin, height - doc.topMargin)\n\n    # Add the logo\n    logo_path = f\"{report_path}/assets/img/logo/report_logo.png\"\n    logo = Image(logo_path, width=150, height=30)\n\n    # Aligning logo vertically with the text\n    logo_y = height - doc.topMargin + 5\n    logo.drawOn(canvas, width - 150 - doc.rightMargin, logo_y)\n\n    # Line below the header\n    canvas.setStrokeColor(HexColor(\"#115e59\"))\n    canvas.setLineWidth(1)\n    line_y = height - doc.topMargin - 10\n    canvas.line(doc.leftMargin, line_y, width - doc.rightMargin, line_y)\n\n    # Footer\n    footer_padding = 15  # Add padding under the page number\n    canvas.setStrokeColor(HexColor(\"#115e59\"))\n    canvas.line(40, 60 + footer_padding, A4[0] - 40, 60 + footer_padding)\n\n    canvas.setFont(\"Helvetica\", 8)\n    canvas.drawString(A4[0] / 2 - 30, 60 + footer_padding - 15, f\"Page {doc.page}\")\n\n    canvas.setFont(\"Helvetica-Oblique\", 8)\n    canvas.setFillColor(HexColor(\"#9cafae\"))\n    canvas.drawCentredString(\n        A4[0] / 2,\n        40,\n        \"EscapeCloud Community Edition - This report is provided 'As Is,' without any warranty of any kind.\",\n    )\n    canvas.drawCentredString(\n        A4[0] / 2,\n        30,\n        \"EscapeCloud makes no warranty that the information contained in this report is complete or error-free. Copyright 2024-2025\",\n    )\n\n    # Restore the state of the canvas\n    canvas.restoreState()\n\n\ndef draw_risk_chart(risk_chart_data: Dict[str, int]) -> Drawing:\n    # Define colors for each severity and their border colors\n    severity_colors = {\n        \"high\": HexColor(\"#991b1b\"),\n        \"medium\": HexColor(\"#ffae1f\"),\n        \"low\": HexColor(\"#539bff\"),\n    }\n\n    # Border colors\n    border_colors = {\n        \"high\": HexColor(\"#991b1b\"),\n        \"medium\": HexColor(\"#ffae1f\"),\n        \"low\": HexColor(\"#539bff\"),\n    }\n\n    # Create a drawing for the Doughnut chart\n    d = Drawing(300, 200)\n\n    # Create the Pie (Doughnut) chart\n    pie = Pie()\n    pie.x = 100\n    pie.y = 25\n    pie.width = 150\n    pie.height = 150\n    pie.data = list(risk_chart_data.values())\n    pie.innerRadiusFraction = 0.5\n\n    # Assign colors and borders for each severity level\n    for i, severity in enumerate(risk_chart_data.keys()):\n        pie.slices[i].fillColor = severity_colors[severity]\n        pie.slices[i].strokeColor = border_colors[severity]\n        pie.slices[i].strokeWidth = 1  # Set the border width\n\n    # Add the Pie chart to the drawing\n    d.add(pie)\n\n    # Create a Legend with headers\n    legend = Legend()\n    legend.x = 280\n    legend.y = 130\n    legend.dxTextSpace = 10\n    legend.columnMaximum = 6\n    legend.alignment = \"right\"\n    legend.subCols[0].minWidth = 60\n    legend.subCols[1].minWidth = 30\n    legend.colorNamePairs = [\n        (severity_colors[severity], (severity, str(risk_chart_data[severity])))\n        for severity in risk_chart_data.keys()\n    ]\n\n    # Configure sub-columns for the legend\n    legend.subCols[0].align = \"left\"\n    legend.subCols[1].align = \"right\"\n\n    # Add the Legend to the drawing\n    d.add(legend)\n\n    # Create a Legend Header\n    legend_header = Legend()\n    legend_header.x = 280\n    legend_header.y = 150\n    legend_header.dxTextSpace = 10\n    legend_header.colorNamePairs = [\n        (HexColor(\"#FFFFFF\"), (\"Severity\", \"No.\"))\n    ]  # Corrected line\n    legend_header.alignment = \"right\"\n    legend_header.subCols[0].align = \"left\"\n    legend_header.subCols[0].minWidth = 60\n    legend_header.subCols[1].align = \"right\"\n    legend_header.subCols[1].minWidth = 30\n\n    # Add the Legend Header to the drawing\n    d.add(legend_header)\n\n    return d\n\n\ndef draw_cost_chart(months: List[str], costs: List[float]) -> Drawing:\n    # Create a drawing for the bar chart\n    d = Drawing(7.5 * cm, 5 * cm)\n\n    # Create a Vertical Bar Chart\n    bar_chart = VerticalBarChart()\n    bar_chart.x = 20\n    bar_chart.y = 20\n    bar_chart.width = 6.5 * cm\n    bar_chart.height = 4 * cm\n    bar_chart.data = [costs]\n    bar_chart.barWidth = 0.8 * cm\n\n    # Style the bars\n    bar_chart.bars[0].fillColor = HexColor(\"#055160\")\n    bar_chart.bars[0].strokeColor = HexColor(\"#055160\")\n\n    # Set the categories (months)\n    bar_chart.categoryAxis.categoryNames = months\n\n    # Calculate valueMax\n    max_cost = max(costs) if costs else 0\n    bar_chart.valueAxis.valueMax = (\n        math.ceil(max_cost / 10.0) * 10 if max_cost > 0 else 10\n    )\n    bar_chart.valueAxis.valueMin = 0\n\n    # Add the bar chart to the drawing\n    d.add(bar_chart)\n\n    return d\n\n\ndef draw_exitscore_chart(\n    exit_score: int, output_path: str, width: int = 750, height: int = 500\n) -> str:\n    # Create the gauge chart\n    fig = go.Figure(\n        go.Indicator(\n            mode=\"gauge+number\",\n            value=exit_score,\n            domain={\"x\": [0, 1], \"y\": [0, 1]},\n            gauge={\n                \"axis\": {\"range\": [0, 100], \"tickwidth\": 0.2, \"tickcolor\": \"darkgray\"},\n                \"bar\": {\"color\": \"#f3f6f6\", \"thickness\": 0.2},\n                \"steps\": [\n                    {\"range\": [0, 20], \"color\": \"#ba1c1d\"},\n                    {\"range\": [20, 40], \"color\": \"#ff9533\"},\n                    {\"range\": [40, 60], \"color\": \"#f1ca00\"},\n                    {\"range\": [60, 80], \"color\": \"#76c31d\"},\n                    {\"range\": [80, 100], \"color\": \"#065f43\"},\n                ],\n            },\n        )\n    )\n\n    image_file = os.path.join(output_path, \"exit_score_chart.png\")\n    fig.write_image(image_file, width=width, height=height)\n\n    return image_file\n\n\ndef draw_vendor_lockin_radar_chart(\n    human: int, technology: int, operational: int\n) -> Drawing:\n    # Create a drawing for the radar chart\n    d = Drawing(350, 250)\n\n    # Define the labels and data\n    labels = [\"Human\", \"Technology\", \"Operational\"]\n    data = [human, technology, operational]\n\n    # Define your hex color with alpha\n    bg_color = HexColor(\"#4BC0C0\")\n    bg_color.alpha = 0.2  # Set alpha for the fill color\n    border_color = HexColor(\"#4BC0C0\")  # Border color with default alpha (1)\n\n    # Normalize data\n    max_value = 5\n    normalized_data = [i / max_value for i in data]\n\n    # Define the number of facets and calculate the angle of each facet\n    num_facets = len(labels)\n    angle = 360 / num_facets\n\n    # Adjust the starting angle for pyramid orientation\n    start_angle = -30\n\n    # Define the center and radius of the radar chart\n    cx = 230\n    cy = 125\n    radius = 100  # Radius\n\n    # Draw concentric polygons\n    for level in range(1, int(max_value) + 1):\n        points = []\n        for i in range(num_facets):\n            x = cx + radius * cos(radians(start_angle + i * angle))\n            y = cy + radius * sin(radians(start_angle + i * angle))\n            points.extend([x, y])\n        d.add(Polygon(points, fillColor=None, strokeColor=colors.grey))\n\n    # Draw lines connecting the vertices of concentric polygons\n    for level in range(1, int(max_value)):\n        prev_x = None\n        prev_y = None\n        first_x = None\n        first_y = None\n        for i in range(num_facets):\n            x = cx + (radius * level / max_value) * cos(\n                radians(start_angle + i * angle)\n            )\n            y = cy + (radius * level / max_value) * sin(\n                radians(start_angle + i * angle)\n            )\n\n            # Store the first x and y coordinates to close the triangle later\n            if i == 0:\n                first_x = x\n                first_y = y\n\n            # If not the first vertex, draw a line from the previous vertex to the current vertex\n            if prev_x is not None and prev_y is not None:\n                d.add(Line(prev_x, prev_y, x, y, strokeColor=colors.grey))\n\n            prev_x = x\n            prev_y = y\n\n        # Close the triangle by drawing a line from the last vertex to the first vertex\n        d.add(Line(prev_x, prev_y, first_x, first_y, strokeColor=colors.grey))\n\n    # Draw the data polygon\n    points = []\n    for i in range(num_facets):\n        x = cx + radius * normalized_data[i] * cos(radians(start_angle + i * angle))\n        y = cy + radius * normalized_data[i] * sin(radians(start_angle + i * angle))\n        points.extend([x, y])\n    d.add(Polygon(points, fillColor=bg_color, strokeColor=border_color))\n\n    # Draw labels\n    for i in range(num_facets):\n        x = cx + radius * cos(radians(start_angle + i * angle))\n        y = cy + radius * sin(radians(start_angle + i * angle))\n        d.add(Line(cx, cy, x, y, strokeColor=colors.grey))\n\n        # Adjust label position and anchor based on quadrant\n        anchor = \"middle\"  # Default text anchor\n\n        label_text = labels[i]\n\n        # Adjust padding and anchor for different quadrants if necessary\n        if i * angle > 90 and i * angle < 270:\n            anchor = \"end\" if i * angle < 180 else \"start\"  # Adjust text anchor\n\n        label_padding = (\n            10  # Additional padding to move the label slightly outward from max_value\n        )\n        label_x = cx + (radius + label_padding) * cos(radians(start_angle + i * angle))\n        label_y = cy + (radius + label_padding) * sin(radians(start_angle + i * angle))\n\n        # Adjustments based on label\n        if label_text == \"Technology\":\n            label_x += 25\n            label_y -= 10\n        elif label_text == \"Operational\":\n            label_x -= 50\n            label_y -= 10\n        elif label_text == \"Human\":\n            label_y += 5\n            label_x += 10\n\n        d.add(\n            String(\n                label_x,\n                label_y,\n                label_text,\n                fontSize=10,\n                fillColor=colors.black,\n                textAnchor=anchor,\n            )\n        )\n\n    return d\n"
  },
  {
    "path": "core/utils_sync.py",
    "content": "# core/utils_sync.py\nfrom __future__ import annotations\n\nimport json\nimport logging\nimport os\nimport time\nimport config\nimport requests\nfrom typing import Any, Dict, List\n\nfrom core.utils_db import load_data\n\n# Configure logger\nlogger = logging.getLogger(\"core.engine.sync\")\nlogger.setLevel(logging.INFO)\n\n_ASSESS_PATH = \"/api/v1/assessments/\"\n\n\ndef _assess_url(host: str) -> str:\n    host = host.strip().rstrip(\"/\")\n    if not host.startswith(\"http\"):\n        host = f\"https://{host}\"\n    return f\"{host}{_ASSESS_PATH}\"\n\n\ndef _build_payload(\n    *,\n    report_path: str,\n    name: str,\n    started_at: int,\n    exit_strategy: int,\n    cloud_service_provider: int,\n    assessment_type: int,\n) -> Dict[str, Any]:\n    db_path = os.path.join(report_path, \"data\", \"assessment.db\")\n\n    resource_rows: List[Dict[str, Any]] = load_data(\n        \"resource_inventory\", db_path=db_path\n    )\n    cost_rows: List[Dict[str, Any]] = load_data(\"cost_inventory\", db_path=db_path)\n\n    res_payload = [\n        {\n            \"id\": int(r[\"resource_type\"]),\n            \"location\": r.get(\"location\") or \"unknown\",\n            \"count\": int(r.get(\"count\", 0)),\n        }\n        for r in resource_rows\n    ]\n    cost_payload = [\n        {\n            \"month\": c[\"month\"],\n            \"cost\": float(c[\"cost\"]),\n            \"currency\": c[\"currency\"],\n        }\n        for c in cost_rows\n    ]\n\n    engine_version = getattr(config, \"CLI_VERSION\", \"v1.0.0\").strip()\n    now = int(time.time())\n\n    payload: Dict[str, Any] = {\n        \"id\": os.urandom(16).hex(),\n        \"object\": \"event\",\n        \"cli_version\": engine_version,\n        \"created\": now,\n        \"type\": \"local.assessment.succeeded\",\n        \"data\": {\n            \"name\": name,\n            \"exit_strategy\": exit_strategy,\n            \"cloud_service_provider\": cloud_service_provider,\n            \"assessmentType\": assessment_type,\n            \"started_at\": started_at,\n            \"completed_at\": now,\n            \"success\": True,\n            \"resource_inventory\": res_payload,\n            \"cost_inventory\": cost_payload,\n        },\n    }\n\n    logger.debug(\"Outgoing payload:\\n%s\", json.dumps(payload, indent=2))\n    return payload\n\n\ndef post_assessment(\n    *,\n    name: str,\n    started_at: int,\n    report_path: str,\n    meta: Dict[str, int],\n    token: str,\n    timeout: int = 10,\n) -> Dict[str, Any]:\n    host = getattr(config, \"HOST\", \"\").strip()\n    if not host:\n        return {\"success\": False, \"payload\": None, \"logs\": \"HOST missing in config.py\"}\n\n    url = _assess_url(host)\n    headers = {\"Authorization\": f\"Bearer {token}\", \"Content-Type\": \"application/json\"}\n\n    payload = _build_payload(\n        report_path=report_path,\n        started_at=started_at,\n        name=name,\n        exit_strategy=meta[\"exit_strategy\"],\n        cloud_service_provider=meta[\"cloud_service_provider\"],\n        assessment_type=meta[\"assessment_type\"],\n    )\n\n    try:\n        resp = requests.post(url, headers=headers, json=payload, timeout=timeout)\n        ok = resp.ok\n        return {\n            \"success\": ok,\n            \"payload\": resp.json() if ok else None,\n            \"logs\": f\"server responded {resp.status_code}\",\n        }\n    except requests.RequestException as exc:\n        return {\"success\": False, \"payload\": None, \"logs\": f\"POST failed: {exc}\"}\n"
  },
  {
    "path": "main.py",
    "content": "# main.py\nimport logging\nimport argparse\nimport boto3\nimport time\nimport sys\nfrom rich.console import Console\nfrom datetime import datetime\nfrom botocore.exceptions import NoCredentialsError, ProfileNotFound\nfrom azure.identity import DefaultAzureCredential, ClientSecretCredential\nfrom azure.mgmt.resource import SubscriptionClient, ResourceManagementClient\n\n# Import the functions\nfrom core.engine import (\n    verify_credentials,\n    test_permissions,\n    create_resource_inventory,\n    create_cost_inventory,\n    perform_risk_assessment,\n    sync_assessment,\n    generate_report,\n)\nfrom utils.azure import (\n    select_subscription,\n    select_resource_group,\n    is_azure_cli_installed,\n    is_azure_cli_logged_in,\n    is_azure_cli_token_expired,\n)\nfrom utils.aws import is_aws_cli_installed, is_aws_profile_valid\nfrom utils.connection import resolve_mode\nfrom utils.data import initialize_dataset\nfrom utils.utils import (\n    ascii_art,\n    create_directory,\n    load_config,\n    prompt_required_inputs,\n    print_help_message,\n    print_step,\n)\nfrom utils.validate import validate_region, validate_config\n\n# Configure the root logger to ensure logs propagate from all modules\nlogging.basicConfig(\n    level=logging.INFO, format=\"%(asctime)s - %(name)s - %(levelname)s - %(message)s\"\n)\nlogging.getLogger(\"botocore\").setLevel(logging.WARNING)\nlogging.getLogger(\"boto3\").setLevel(logging.WARNING)\nlogging.getLogger(\"kaleido\").setLevel(logging.WARNING)\nlogging.getLogger(\"choreographer\").setLevel(logging.WARNING)\n\n# Configure the logger\nlogger = logging.getLogger(__name__)\nlogger.setLevel(logging.INFO)\n\n# Initialize the console object\nconsole = Console()\n\n\ndef handle_aws(args):\n    config = {}\n\n    cloud_provider = 2\n\n    if args.config:\n        # logger.info(f\"AWS --config argument detected with path: {args.config}\")\n        config = load_config(args.config)\n\n        if not config:\n            console.print(\"[red]Invalid or missing AWS configuration file.[/red]\")\n            return\n\n        # Handle name field logic (priority: --name > config name > fallback)\n        if args.name:\n            config[\"name\"] = args.name.strip()\n\n        if \"name\" not in config or not config[\"name\"].strip():\n            config[\"name\"] = (\n                f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n            )\n\n    elif args.profile:\n        # Check if aws cli available\n        if not is_aws_cli_installed():\n            # logger.error(\"AWS CLI is not installed.\")\n            console.print(\n                \"[red]AWS CLI is not installed. Install it from https://aws.amazon.com/cli/[/red]\"\n            )\n            return\n        # Check if aws cli profile is valid\n        if not is_aws_profile_valid(args.profile):\n            # logger.error(f\"AWS profile '{args.profile}' is not configured.\")\n            console.print(\n                f\"[red]AWS profile '{args.profile}' is not configured. Use `aws configure --profile {args.profile}`.[/red]\"\n            )\n            return\n\n        # logger.info(f\"AWS --profile argument detected with profile: {args.profile}\")\n        try:\n            session = boto3.Session(profile_name=args.profile)\n            credentials = session.get_credentials()\n            if credentials is None:\n                # logger.error(f\"AWS profile '{args.profile}' has no valid credentials.\")\n                console.print(\n                    f\"[red]AWS profile '{args.profile}' has no valid credentials. Use `aws configure --profile {args.profile}`.[/red]\"\n                )\n                return\n            region = session.region_name or \"us-east-1\"\n            # logger.info(f\"Using AWS profile '{args.profile}' with region '{region}'.\")\n\n            exit_strategy, assessment_type = prompt_required_inputs()\n            config = {\n                \"name\": (\n                    args.name.strip()\n                    if args.name\n                    else f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n                ),\n                \"cloudServiceProvider\": cloud_provider,\n                \"exitStrategy\": exit_strategy,\n                \"assessmentType\": assessment_type,\n                \"providerDetails\": {\n                    \"accessKey\": credentials.access_key,\n                    \"secretKey\": credentials.secret_key,\n                    \"region\": region,\n                },\n            }\n        except (NoCredentialsError, ProfileNotFound) as e:\n            # logger.error(f\"AWS profile error: {e}\", exc_info=True)\n            console.print(\n                f\"[red]AWS profile error: {str(e)}. Use `aws configure` to set up a profile.[/red]\"\n            )\n            return\n    else:\n        exit_strategy, assessment_type = prompt_required_inputs()\n        # Prompt for manual input\n        try:\n            access_key = input(\"Enter AWS Access Key: \").strip()\n            secret_key = input(\"Enter AWS Secret Key: \").strip()\n\n            # Validate AWS region input\n            while True:\n                region = input(\"Enter AWS region: \").strip()\n                try:\n                    validate_region(region)\n                    break\n                except ValueError as e:\n                    console.print(f\"[red]{e} Please enter a valid AWS region.[/red]\")\n\n            config = {\n                \"name\": (\n                    args.name.strip()\n                    if args.name\n                    else f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n                ),\n                \"cloudServiceProvider\": cloud_provider,\n                \"exitStrategy\": exit_strategy,\n                \"assessmentType\": assessment_type,\n                \"providerDetails\": {\n                    \"accessKey\": access_key,\n                    \"secretKey\": secret_key,\n                    \"region\": region,\n                },\n            }\n        except Exception as e:\n            console.print(f\"[red]Error during manual AWS configuration: {e}[/red]\")\n            return\n\n    # Run the AWS assessment pipeline\n    run_assessment(config, \"aws\")\n\n\ndef handle_azure(args):\n    config = {}\n\n    cloud_provider = 1\n\n    if args.config:\n        # logger.info(f\"Azure --config argument detected with path: {args.config}\")\n        config = load_config(args.config)\n\n        if not config:\n            console.print(\"[red]Invalid or missing Azure configuration file.[/red]\")\n            return\n\n        # Handle name field logic (priority: --name > config name > fallback)\n        if args.name:\n            config[\"name\"] = args.name.strip()\n\n        if \"name\" not in config or not config[\"name\"].strip():\n            config[\"name\"] = (\n                f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n            )\n\n    elif args.cli:\n        # logger.info(\"Azure --cli argument detected. Using Azure CLI credentials.\")\n        # Check if az cli available\n        if not is_azure_cli_installed():\n            # logger.error(\"Azure CLI is not installed.\")\n            console.print(\n                \"[red]Azure CLI is not installed. Install it from https://aka.ms/install-azure-cli.[/red]\"\n            )\n            return\n\n        # Check if the user is logged in to Azure CLI\n        if not is_azure_cli_logged_in():\n            # logger.error(\"User is not logged in to Azure CLI\")\n            console.print(\n                \"[red]You are not logged in to Azure CLI. Please run 'az login' and try again.[/red]\"\n            )\n            return\n\n        # Check if the cli token is expired\n        if is_azure_cli_token_expired():\n            # logger.error(\"Azure CLI token is expired.\")\n            console.print(\"[red]Your Azure CLI token has expired. Please run:[/red]\")\n            console.print(\n                \"[bold cyan]az login --scope https://management.azure.com/.default[/bold cyan]\"\n            )\n            return\n\n        try:\n            credential = DefaultAzureCredential()\n            tenant_id = input(\"Enter Azure Tenant ID: \").strip()\n            subscription_client = SubscriptionClient(credential)\n            subscriptions = list(subscription_client.subscriptions.list())\n            if not subscriptions:\n                logger.error(\n                    \"No subscriptions found for the provided Azure credentials.\"\n                )\n                console.print(\n                    \"[red]No subscriptions found for the provided credentials.[/red]\"\n                )\n                return\n\n            selected_subscription = select_subscription(subscriptions)\n            subscription_id = selected_subscription.subscription_id\n\n            resource_client = ResourceManagementClient(credential, subscription_id)\n            resource_groups = list(resource_client.resource_groups.list())\n            if not resource_groups:\n                logger.error(\"No resource groups found in the selected subscription.\")\n                console.print(\n                    \"[red]No resource groups found in the selected subscription.[/red]\"\n                )\n                return\n\n            resource_group_name = select_resource_group(resource_groups)\n            exit_strategy, assessment_type = prompt_required_inputs()\n            config = {\n                \"name\": (\n                    args.name.strip()\n                    if args.name\n                    else f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n                ),\n                \"cloudServiceProvider\": cloud_provider,\n                \"exitStrategy\": exit_strategy,\n                \"assessmentType\": assessment_type,\n                \"providerDetails\": {\n                    \"credential\": credential,\n                    \"tenantId\": tenant_id,\n                    \"subscriptionId\": subscription_id,\n                    \"resourceGroupName\": resource_group_name,\n                },\n            }\n        except Exception as e:\n            logger.error(f\"Error during Azure CLI processing: {e}\", exc_info=True)\n            console.print(f\"[red]An error occurred: {e}[/red]\")\n    else:\n        exit_strategy, assessment_type = prompt_required_inputs()\n\n        tenant_id = input(\"Enter Azure Tenant ID: \").strip()\n        client_id = input(\"Enter Service Principal / Client ID: \").strip()\n        client_secret = input(\"Enter Client Secret: \").strip()\n\n        try:\n            # Authenticate using the provided credentials\n            credential = ClientSecretCredential(\n                tenant_id=tenant_id, client_id=client_id, client_secret=client_secret\n            )\n            subscription_client = SubscriptionClient(credential)\n\n            # Fetch and prompt the user to select a subscription\n            subscriptions = list(subscription_client.subscriptions.list())\n            if not subscriptions:\n                console.print(\n                    \"[red]No subscriptions found. Please check your credentials.[/red]\"\n                )\n                return\n\n            selected_subscription = select_subscription(subscriptions)\n            subscription_id = selected_subscription.subscription_id\n\n            # Fetch and prompt the user to select a resource group\n            resource_client = ResourceManagementClient(credential, subscription_id)\n            resource_groups = list(resource_client.resource_groups.list())\n            if not resource_groups:\n                console.print(\n                    \"[red]No resource groups found in the selected subscription.[/red]\"\n                )\n                return\n\n            resource_group_name = select_resource_group(resource_groups)\n\n            # Build the configuration\n            config = {\n                \"name\": (\n                    args.name.strip()\n                    if args.name\n                    else f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n                ),\n                \"cloudServiceProvider\": cloud_provider,\n                \"exitStrategy\": exit_strategy,\n                \"assessmentType\": assessment_type,\n                \"providerDetails\": {\n                    \"tenantId\": tenant_id,\n                    \"clientId\": client_id,\n                    \"clientSecret\": client_secret,\n                    \"subscriptionId\": subscription_id,\n                    \"resourceGroupName\": resource_group_name,\n                },\n            }\n        except Exception as e:\n            logger.error(f\"Error during manual Azure configuration: {e}\", exc_info=True)\n            console.print(f\"[red]An error occurred: {e}[/red]\")\n            return\n\n    # Run the Azure assessment pipeline\n    # logger.info(\"Starting Azure assessment pipeline.\")\n    run_assessment(config, \"azure\")\n\n\ndef run_assessment(config, provider_name):\n    # Record the assessment start time to propagate across stages\n    started_at = int(time.time())\n\n    try:\n        # Preliminary Stage: Validate configuration & create directory\n        console.print(\"-------------------------------------------\")\n        console.print(\"Preliminary Stage\", style=\"bold\")\n        try:\n            validate_config(config)\n            print_step(\"Configuration successfully validated.\", status=\"ok\")\n        except ValueError as e:\n            print_step(\"Configuration validation failed.\", status=\"error\", logs=str(e))\n            return\n\n        # Detect ExitCloud Integration\n        mode, jwt = resolve_mode()\n        if mode == \"online\":\n            print_step(\"ExitCloud integration configured.\", status=\"ok\")\n        else:\n            print_step(\"ExitCloud integration not configured.\", status=\"warning\")\n            # Overwrite assessment type to basic\n            if config[\"assessmentType\"] != 1:\n                print_step(\n                    \"Forcing Basic Assessment due to offline mode.\", status=\"warning\"\n                )\n                config[\"assessmentType\"] = 1\n\n        # Create directories\n        try:\n            report_path, raw_data_path = create_directory()\n            print_step(\"Directory successfully created.\", status=\"ok\")\n        except RuntimeError as e:\n            print_step(\"Directory creation failed.\", status=\"error\", logs=str(e))\n            return\n\n        # Handle the result\n        provider_name = (\n            \"Microsoft Azure\"\n            if config[\"cloudServiceProvider\"] == 1\n            else \"AWS\" if config[\"cloudServiceProvider\"] == 2 else \"Unknown\"\n        )\n\n        # Stage 1: Verify Credentials\n        console.print(\"-------------------------------------------\")\n        console.print(\"Stage #1 - Validate Credentials\", style=\"bold\")\n        # Test Connection\n        connection_success, logs = verify_credentials(\n            config[\"cloudServiceProvider\"], config[\"providerDetails\"]\n        )\n        if connection_success:\n            print_step(f\"Connecting to {provider_name}...\", status=\"ok\")\n        else:\n            print_step(f\"Connecting to {provider_name}...\", status=\"error\")\n            console.print(f\"   ↳ {logs}\", style=\"dim\")\n            logger.error(f\"Credential verification failed: {logs}\")\n            return\n        console.print(\"-------------------------------------------\")\n\n        # Stage 2: Test Permissions\n        console.print(\"Stage #2 - Validate Permissions\", style=\"bold\")\n\n        # Labels for permission types\n        permission_reader_label = (\n            \"Reader\" if config[\"cloudServiceProvider\"] == 1 else \"ViewOnlyAccess\"\n        )\n        permission_cost_label = (\n            \"Cost Management Reader\"\n            if config[\"cloudServiceProvider\"] == 1\n            else \"AWSBillingReadOnlyAccess\"\n        )\n\n        # Test permissions with spinners\n        with console.status(\"Validating permissions...\", spinner=\"dots\"):\n            permission_valid, permission_reader, permission_cost, logs = (\n                test_permissions(\n                    config[\"cloudServiceProvider\"], config[\"providerDetails\"]\n                )\n            )\n\n        # Output results for permission checks\n        if permission_reader:\n            print_step(f\"Checking {permission_reader_label}...\", status=\"ok\")\n        else:\n            print_step(\n                f\"Checking {permission_reader_label}...\", status=\"error\", logs=logs\n            )\n\n        if permission_cost:\n            print_step(f\"Checking {permission_cost_label}...\", status=\"ok\")\n        else:\n            print_step(\n                f\"Checking {permission_cost_label}...\", status=\"error\", logs=logs\n            )\n\n        # Exit if permissions are invalid\n        if not permission_valid:\n            logger.error(f\"Permission validation failed: {logs}\")\n            return\n\n        console.print(\"-------------------------------------------\")\n\n        # Stage 3: Build Resource Inventory\n        console.print(\"Stage #3 - Build Resource Inventory\", style=\"bold\")\n\n        # Use a spinner to indicate progress\n        with console.status(\n            f\"Building resource inventory for {provider_name}...\", spinner=\"dots\"\n        ):\n            result = create_resource_inventory(\n                config[\"cloudServiceProvider\"],\n                config[\"providerDetails\"],\n                report_path,\n                raw_data_path,\n            )\n\n        if result[\"success\"]:\n            print_step(\n                f\"Building resource inventory for {provider_name}...\", status=\"ok\"\n            )\n        else:\n            print_step(\n                f\"Building resource inventory for {provider_name}...\",\n                status=\"error\",\n                logs=result[\"logs\"],\n            )\n            return\n\n        console.print(\"-------------------------------------------\")\n\n        # Stage 4: Build Cost Inventory\n        console.print(\"Stage #4 - Build Cost Inventory\", style=\"bold\")\n\n        # Use a spinner to indicate progress\n        with console.status(\n            f\"Building cost inventory for {provider_name}...\", spinner=\"dots\"\n        ):\n            cost_result = create_cost_inventory(\n                config[\"cloudServiceProvider\"],\n                config[\"providerDetails\"],\n                report_path,\n                raw_data_path,\n            )\n\n        # Handle the result\n        if cost_result[\"success\"]:\n            print_step(f\"Building cost inventory for {provider_name}...\", status=\"ok\")\n        else:\n            print_step(\n                f\"Building cost inventory for {provider_name}...\",\n                status=\"error\",\n                logs=cost_result[\"logs\"],\n            )\n            return\n\n        console.print(\"-------------------------------------------\")\n\n        name = (\n            config.get(\"name\")\n            or f\"Exit Assessment {datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n        )\n\n        # Stage 5 – Online / Offline Risk Assessment\n        if mode == \"online\":\n            console.print(\"Stage #5 – Online Risk Assessment\", style=\"bold\")\n\n            sync_result = sync_assessment(\n                name=name,\n                started_at=started_at,\n                report_path=report_path,\n                metadata={\n                    \"cloud_service_provider\": config[\"cloudServiceProvider\"],\n                    \"exit_strategy\": config[\"exitStrategy\"],\n                    \"assessment_type\": config[\"assessmentType\"],\n                },\n                mode=mode,\n                token=jwt,\n            )\n\n            status = \"ok\" if sync_result[\"success\"] else \"error\"\n            print_step(\"Sync assessment...\", status=status, logs=sync_result[\"logs\"])\n\n        elif mode == \"offline\":\n            console.print(\"Stage #5 – Offline Risk Assessment\", style=\"bold\")\n\n            with console.status(\"Performing risk assessment...\", spinner=\"dots\"):\n                risk_result = perform_risk_assessment(\n                    exit_strategy=config[\"exitStrategy\"],\n                    report_path=report_path,\n                    mode=mode,\n                )\n\n            status = \"ok\" if risk_result[\"success\"] else \"error\"\n            print_step(\n                \"Performing risk assessment...\", status=status, logs=risk_result[\"logs\"]\n            )\n\n        console.print(\"-------------------------------------------\")\n\n        # Stage 6: Generate Report\n        console.print(\"Stage #6 - Generate Report\", style=\"bold\")\n\n        # Use a spinner to indicate progress\n        with console.status(\"Generating report...\", spinner=\"dots\"):\n            report_status = generate_report(\n                config[\"cloudServiceProvider\"],\n                config[\"providerDetails\"],\n                config[\"exitStrategy\"],\n                config[\"assessmentType\"],\n                name,\n                report_path,\n                raw_data_path,\n            )\n\n        # Handle the result\n        if report_status[\"success\"]:\n            print_step(\"Generating report...\", status=\"ok\")\n        else:\n            print_step(\n                \"Generating report...\", status=\"error\", logs=report_status[\"logs\"]\n            )\n            return\n\n        # Output the report path after the separator\n        console.print(\"-------------------------------------------\")\n        console.print(\"Outputs:\", style=\"bold\")\n        html_report_path = report_status.get(\"reports\", {}).get(\"HTML\")\n        if html_report_path:\n            console.print(f\"HTML Report: {html_report_path}\", style=\"cyan\")\n        pdf_report_path = report_status.get(\"reports\", {}).get(\"PDF\")\n        if pdf_report_path:\n            console.print(f\"PDF Report: {pdf_report_path}\", style=\"cyan\")\n        json_report_path = report_status.get(\"reports\", {}).get(\"JSON\")\n        if html_report_path:\n            console.print(f\"JSON Report: {json_report_path}\", style=\"cyan\")\n        console.print(\"-------------------------------------------\")\n\n    except Exception as e:\n        console.print(f\"[red]Unexpected error: {e}[/red]\")\n\n\ndef parse_arguments():\n    parser = argparse.ArgumentParser(\n        description=\"EscapeCloud - Community Edition\",\n        epilog=(\n            \"Example usage:\\n\"\n            \"  python3 main.py aws                        # Use manual input for AWS\\n\"\n            \"  python3 main.py aws --config config.json   # Use a configuration file for AWS\\n\"\n            \"  python3 main.py aws --profile PROFILE      # Use an AWS CLI profile\\n\"\n            \"  python3 main.py aws --name 'DMS System'    # Use a pre-defined assessment name\\n\"\n            \"  python3 main.py azure                      # Use manual input for Azure\\n\"\n            \"  python3 main.py azure --config config.json # Use a configuration file for Azure\\n\"\n            \"  python3 main.py azure --cli                # Use Azure CLI credentials\\n\"\n            \"  python3 main.py azure --name 'DMS System'  # Use a pre-defined assessment name\\n\"\n        ),\n        formatter_class=argparse.RawDescriptionHelpFormatter,\n    )\n\n    subparsers = parser.add_subparsers(\n        dest=\"cloud_provider\", help=\"Specify the cloud provider (aws or azure).\"\n    )\n\n    # Subparser for AWS\n    aws_parser = subparsers.add_parser(\"aws\", help=\"Perform an AWS assessment.\")\n    aws_group = aws_parser.add_mutually_exclusive_group(required=False)\n    aws_group.add_argument(\n        \"--config\", type=str, help=\"Path to the configuration file (JSON format).\"\n    )\n    aws_group.add_argument(\n        \"--profile\",\n        type=str,\n        help=\"AWS profile name to use credentials from ~/.aws/credentials.\",\n    )\n    aws_parser.add_argument(\n        \"--name\", type=str, help=\"Assessment Name (Optional / Max. 50 characters).\"\n    )\n\n    # Subparser for Azure\n    azure_parser = subparsers.add_parser(\"azure\", help=\"Perform an Azure assessment.\")\n    azure_group = azure_parser.add_mutually_exclusive_group(required=False)\n    azure_group.add_argument(\n        \"--config\", type=str, help=\"Path to the configuration file (JSON format).\"\n    )\n    azure_group.add_argument(\n        \"--cli\",\n        action=\"store_true\",\n        help=\"Use Azure CLI credentials for authentication.\",\n    )\n    azure_parser.add_argument(\n        \"--name\", type=str, help=\"Assessment Name (Optional / Max. 50 characters).\"\n    )\n\n    return parser.parse_args()\n\n\ndef main():\n    # Print ASCII art\n    console.print(ascii_art, style=\"bold cyan\")\n\n    # Ensure latest dataset is available before proceeding\n    initialize_dataset()\n\n    args = parse_arguments()\n\n    # Check if the cloud provider is specified\n    if not args.cloud_provider:\n        print_help_message()\n        return\n\n    # Dispatch based on provided arguments\n    if args.cloud_provider == \"aws\":\n        handle_aws(args)\n    elif args.cloud_provider == \"azure\":\n        handle_azure(args)\n    else:\n        console.print(\n            \"[red]Invalid command. Use 'aws' or 'azure' as the first argument.[/red]\"\n        )\n        console.print(\n            \"[green]Run 'python3 main.py --help' for usage instructions.[/green]\"\n        )\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except KeyboardInterrupt:\n        console.print(\n            \"\\n[bold yellow]Operation cancelled by user (Ctrl+C). Exiting gracefully.[/bold yellow]\"\n        )\n        # logger.warning(\"Process interrupted by user via KeyboardInterrupt.\")\n        sys.exit(0)\n    except Exception as e:\n        # logger.error(f\"An unexpected error occurred: {e}\", exc_info=True)\n        console.print(f\"[red]Unexpected error: {e}[/red]\")\n        sys.exit(1)\n"
  },
  {
    "path": "publiccode.yml",
    "content": "publiccodeYmlVersion: \"0.4.0\"\nname: cloudexit\nurl: https://github.com/escapecloud/cloudexit\nlandingURL: https://escapecloud.io\nplatforms:\n  - linux\n  - mac\n  - windows\ncategories:\n  - cloud-management\ndevelopmentStatus: beta\nsoftwareType: standalone/backend\n\ndescription:\n  en:\n    shortDescription: Open-source tool for cloud exit assessments\n    longDescription: Open-source tool for cloud exit assessments that helps\n      organizations evaluate risks, dependencies, and alternative strategies\n      before leaving a cloud provider.\n    features:\n      - cloud exit assessment\n      - risk management\n      - alternative technology analysis\n\nlegal:\n  license: AGPL-3.0-only\n\nmaintenance:\n  type: contract\n  contractors:\n    - name: Bence Daniel Hezso\n      until: 2030-12-31\n      email: hello@escapecloud.io\n\nlocalisation:\n  localisationReady: false\n  availableLanguages:\n    - en\n"
  },
  {
    "path": "requirements-dev.txt",
    "content": "-r requirements.txt\nblack\nruff\n"
  },
  {
    "path": "requirements.txt",
    "content": "azure-identity==1.25.3\nazure-mgmt-resource==24.0.0\nazure-mgmt-authorization==4.0.0\nazure-mgmt-costmanagement==4.0.1\nboto3==1.43.5\nbotocore==1.43.5\nrich==15.0.0\nJinja2==3.1.6\nreportlab==4.5.0\npillow==12.2.0\nplotly==6.7.0\nkaleido==1.3.0\nrequests==2.33.1\n"
  },
  {
    "path": "tests/__init__.py",
    "content": "\n"
  },
  {
    "path": "tests/report_fixtures.py",
    "content": "import shutil\nfrom pathlib import Path\n\n\ndef build_report_fixture():\n    metadata = {\n        \"name\": \"Smoke Test Assessment\",\n        \"cloud_service_provider\": 2,\n        \"exit_strategy\": 1,\n        \"assessment_type\": 1,\n        \"timestamp\": \"2026-05-07 12:00:00 UTC\",\n    }\n    provider_details = {\n        \"accessKey\": \"AKIA_TEST\",\n        \"secretKey\": \"SECRET_TEST\",\n        \"region\": \"eu-central-1\",\n    }\n    resource_type_mapping = {\n        \"101\": {\n            \"id\": 101,\n            \"code\": \"AWS.EC2.DescribeInstances.Reservations\",\n            \"name\": \"EC2 Instance\",\n            \"icon\": \"/icons/misc/no_image.png\",\n        }\n    }\n    resource_inventory = [\n        {\"resource_type\": 101, \"location\": \"eu-central-1\", \"count\": 2},\n    ]\n    cost_data = [\n        {\"month\": \"2025-11-01\", \"cost\": 10.5, \"currency\": \"USD\"},\n        {\"month\": \"2025-12-01\", \"cost\": 12.0, \"currency\": \"USD\"},\n        {\"month\": \"2026-01-01\", \"cost\": 14.75, \"currency\": \"USD\"},\n        {\"month\": \"2026-02-01\", \"cost\": 11.25, \"currency\": \"USD\"},\n        {\"month\": \"2026-03-01\", \"cost\": 9.0, \"currency\": \"USD\"},\n        {\"month\": \"2026-04-01\", \"cost\": 13.4, \"currency\": \"USD\"},\n    ]\n    risk_definitions = [\n        {\n            \"id\": \"1\",\n            \"name\": \"Limited Alternatives\",\n            \"description\": \"There are only a few alternatives available.\",\n            \"severity\": \"high\",\n        }\n    ]\n    risk_data = [\n        {\"resource_type\": \"101\", \"risk\": \"1\"},\n    ]\n    alternatives = [\n        {\"resource_type\": \"101\", \"strategy_type\": \"1\", \"alternative_technology\": 1},\n    ]\n    alternative_technologies = [\n        {\n            \"id\": 1,\n            \"product_name\": \"OpenStack\",\n            \"product_description\": \"Open source cloud platform.\",\n            \"product_url\": \"https://www.openstack.org/\",\n            \"open_source\": \"t\",\n            \"support_plan\": \"t\",\n            \"status\": \"t\",\n        }\n    ]\n    return {\n        \"metadata\": metadata,\n        \"provider_details\": provider_details,\n        \"resource_type_mapping\": resource_type_mapping,\n        \"resource_inventory\": resource_inventory,\n        \"cost_data\": cost_data,\n        \"risk_definitions\": risk_definitions,\n        \"risk_data\": risk_data,\n        \"alternatives\": alternatives,\n        \"alternative_technologies\": alternative_technologies,\n        \"exit_strategy\": 1,\n    }\n\n\ndef build_empty_report_fixture():\n    metadata = {\n        \"name\": \"Empty State Assessment\",\n        \"cloud_service_provider\": 2,\n        \"exit_strategy\": 1,\n        \"assessment_type\": 2,\n        \"timestamp\": \"2026-05-08 10:00:00 UTC\",\n    }\n    provider_details = {\n        \"accessKey\": \"AKIA_EMPTY\",\n        \"secretKey\": \"SECRET_EMPTY\",\n        \"region\": \"eu-central-1\",\n    }\n    return {\n        \"metadata\": metadata,\n        \"provider_details\": provider_details,\n        \"resource_type_mapping\": {},\n        \"resource_inventory\": [],\n        \"cost_data\": [],\n        \"risk_definitions\": [],\n        \"risk_data\": [],\n        \"alternatives\": [],\n        \"alternative_technologies\": [],\n        \"exit_strategy\": 1,\n    }\n\n\ndef stage_report_assets(report_path: str) -> None:\n    report_assets = Path(report_path) / \"assets\"\n    report_assets.mkdir(parents=True, exist_ok=True)\n\n    source_assets = Path(\"assets\")\n    for folder in (\"css\", \"img\", \"icons\"):\n        shutil.copytree(\n            source_assets / folder,\n            report_assets / folder,\n            dirs_exist_ok=True,\n        )\n"
  },
  {
    "path": "tests/test_report_pipeline.py",
    "content": "import json\nimport tempfile\nimport unittest\nfrom pathlib import Path\n\nfrom core.utils_report import (\n    generate_html_report,\n    generate_json_report,\n    generate_pdf_report,\n)\nfrom core.utils_report_json import transform_cost_inventory_for_json\nfrom tests.report_fixtures import (\n    build_empty_report_fixture,\n    build_report_fixture,\n    stage_report_assets,\n)\n\n\nclass ReportPipelineSmokeTests(unittest.TestCase):\n    def test_generate_html_report_creates_expected_output(self):\n        fixture = build_report_fixture()\n\n        with tempfile.TemporaryDirectory() as report_dir:\n            html_path = generate_html_report(\n                report_dir,\n                fixture[\"metadata\"],\n                fixture[\"resource_type_mapping\"],\n                fixture[\"resource_inventory\"],\n                fixture[\"cost_data\"],\n                None,\n                fixture[\"risk_data\"],\n                fixture[\"risk_definitions\"],\n                fixture[\"alternatives\"],\n                fixture[\"alternative_technologies\"],\n                fixture[\"exit_strategy\"],\n            )\n\n            self.assertTrue(Path(html_path).exists())\n            html = Path(html_path).read_text(encoding=\"utf-8\")\n\n        self.assertIn(\"Smoke Test Assessment\", html)\n        self.assertIn(\"Amazon Web Services\", html)\n        self.assertIn(\"OpenStack\", html)\n        self.assertIn(\"EC2 Instance\", html)\n\n    def test_generate_html_report_renders_empty_state_output(self):\n        fixture = build_empty_report_fixture()\n\n        with tempfile.TemporaryDirectory() as report_dir:\n            html_path = generate_html_report(\n                report_dir,\n                fixture[\"metadata\"],\n                fixture[\"resource_type_mapping\"],\n                fixture[\"resource_inventory\"],\n                fixture[\"cost_data\"],\n                None,\n                fixture[\"risk_data\"],\n                fixture[\"risk_definitions\"],\n                fixture[\"alternatives\"],\n                fixture[\"alternative_technologies\"],\n                fixture[\"exit_strategy\"],\n            )\n\n            self.assertTrue(Path(html_path).exists())\n            html = Path(html_path).read_text(encoding=\"utf-8\")\n\n        self.assertIn(\"Empty State Assessment\", html)\n        self.assertIn(\"No risk data available.\", html)\n        self.assertIn(\"No cost data available.\", html)\n        self.assertIn(\"No exit score data available.\", html)\n        self.assertIn(\"No vendor lock-in score data available.\", html)\n        self.assertIn(\"No resources were discovered during the assessment.\", html)\n        self.assertIn(\"No alternative technologies are available\", html)\n        self.assertNotIn('id=\"risksChart\"', html)\n        self.assertNotIn('id=\"costsChart\"', html)\n        self.assertNotIn('id=\"exitScoreChart\"', html)\n        self.assertNotIn('id=\"vendorLockInScoreChart\"', html)\n\n    def test_generate_json_report_creates_expected_structure(self):\n        fixture = build_report_fixture()\n\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            raw_data_path = Path(tmp_dir) / \"raw_data\"\n            raw_data_path.mkdir()\n\n            json_path = generate_json_report(\n                str(raw_data_path),\n                fixture[\"metadata\"],\n                fixture[\"resource_type_mapping\"],\n                fixture[\"resource_inventory\"],\n                fixture[\"cost_data\"],\n                None,\n                fixture[\"risk_data\"],\n                fixture[\"risk_definitions\"],\n                fixture[\"alternatives\"],\n                fixture[\"alternative_technologies\"],\n                fixture[\"exit_strategy\"],\n            )\n\n            payload = json.loads(Path(json_path).read_text(encoding=\"utf-8\"))\n\n        self.assertEqual(payload[\"meta\"][\"name\"], \"Smoke Test Assessment\")\n        self.assertEqual(\n            payload[\"data\"][\"resource_inventory\"][0][\"resource_name\"], \"EC2 Instance\"\n        )\n        self.assertEqual(payload[\"data\"][\"cost_inventory\"][0][\"month\"], \"2025-11-01\")\n        self.assertEqual(\n            payload[\"data\"][\"alternative_technologies\"][\"1\"][0][\"product_name\"],\n            \"OpenStack\",\n        )\n\n    def test_generate_pdf_report_creates_non_empty_file(self):\n        fixture = build_report_fixture()\n\n        with tempfile.TemporaryDirectory() as report_dir:\n            stage_report_assets(report_dir)\n\n            pdf_path = generate_pdf_report(\n                fixture[\"provider_details\"],\n                report_dir,\n                fixture[\"metadata\"],\n                fixture[\"resource_type_mapping\"],\n                fixture[\"resource_inventory\"],\n                fixture[\"cost_data\"],\n                None,\n                fixture[\"risk_data\"],\n                fixture[\"risk_definitions\"],\n                fixture[\"alternatives\"],\n                fixture[\"alternative_technologies\"],\n                fixture[\"exit_strategy\"],\n            )\n\n            pdf_file = Path(pdf_path)\n\n            self.assertTrue(pdf_file.exists())\n            self.assertGreater(pdf_file.stat().st_size, 0)\n\n\nclass ReportTransformTests(unittest.TestCase):\n    def test_transform_cost_inventory_for_json_sorts_months(self):\n        unsorted_costs = [\n            {\"month\": \"2026-03-01\", \"cost\": 9.0, \"currency\": \"USD\"},\n            {\"month\": \"2026-01-01\", \"cost\": 14.75, \"currency\": \"USD\"},\n            {\"month\": \"2026-02-01\", \"cost\": 11.25, \"currency\": \"USD\"},\n        ]\n\n        transformed = transform_cost_inventory_for_json(unsorted_costs)\n\n        self.assertEqual(\n            [item[\"month\"] for item in transformed],\n            [\"2026-01-01\", \"2026-02-01\", \"2026-03-01\"],\n        )\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_report_transforms.py",
    "content": "import tempfile\nimport unittest\n\nfrom core.utils_report_html import (\n    transform_alt_tech_for_html,\n    transform_cost_inventory_for_html,\n    transform_risk_inventory_for_html,\n)\nfrom core.utils_report_json import (\n    transform_alt_tech_for_json,\n    transform_resource_inventory_for_json,\n    transform_risk_inventory_for_json,\n)\nfrom core.utils_report_pdf import (\n    transform_alt_tech_for_pdf,\n    transform_cost_inventory_for_pdf,\n    transform_resource_inventory_for_pdf,\n    transform_risk_inventory_for_pdf,\n)\n\n\ndef build_resource_type_mapping():\n    return {\n        \"101\": {\n            \"id\": 101,\n            \"code\": \"AWS.EC2.DescribeInstances.Reservations\",\n            \"name\": \"EC2 Instance\",\n            \"icon\": \"/icons/misc/no_image.png\",\n        },\n        \"202\": {\n            \"id\": 202,\n            \"code\": \"AWS.S3.ListBuckets.Buckets\",\n            \"name\": \"S3 Bucket\",\n            \"icon\": \"/icons/misc/no_image.png\",\n        },\n    }\n\n\ndef build_resource_inventory():\n    return [\n        {\"resource_type\": 101, \"location\": \"eu-central-1\", \"count\": 2},\n        {\"resource_type\": 202, \"location\": \"eu-central-1\", \"count\": 1},\n    ]\n\n\ndef build_risk_definitions():\n    return [\n        {\n            \"id\": \"1\",\n            \"name\": \"Limited Alternatives\",\n            \"description\": \"There are only a few alternatives available.\",\n            \"severity\": \"high\",\n        },\n        {\n            \"id\": \"7\",\n            \"name\": \"Large Service Footprint\",\n            \"description\": \"The service footprint is broad.\",\n            \"severity\": \"medium\",\n        },\n    ]\n\n\ndef build_risk_data():\n    return [\n        {\"resource_type\": \"101\", \"risk\": \"1\"},\n        {\"resource_type\": \"202\", \"risk\": \"1\"},\n        {\"resource_type\": \"null\", \"risk\": \"7\"},\n    ]\n\n\ndef build_alternatives():\n    return [\n        {\"resource_type\": 101, \"strategy_type\": 1, \"alternative_technology\": 1},\n        {\"resource_type\": 101, \"strategy_type\": 3, \"alternative_technology\": 2},\n        {\"resource_type\": 202, \"strategy_type\": 1, \"alternative_technology\": 2},\n    ]\n\n\ndef build_alternative_technologies():\n    return [\n        {\n            \"id\": 1,\n            \"product_name\": \"OpenStack\",\n            \"product_description\": \"Open source cloud platform.\",\n            \"product_url\": \"https://www.openstack.org/\",\n            \"open_source\": \"t\",\n            \"support_plan\": \"t\",\n            \"status\": \"t\",\n        },\n        {\n            \"id\": 2,\n            \"product_name\": \"MinIO\",\n            \"product_description\": \"Object storage platform.\",\n            \"product_url\": \"https://min.io/\",\n            \"open_source\": \"t\",\n            \"support_plan\": \"f\",\n            \"status\": \"t\",\n        },\n        {\n            \"id\": 3,\n            \"product_name\": \"Inactive Tech\",\n            \"product_description\": \"Should be ignored.\",\n            \"product_url\": \"https://example.com/\",\n            \"open_source\": \"t\",\n            \"support_plan\": \"t\",\n            \"status\": \"f\",\n        },\n    ]\n\n\nclass HtmlTransformTests(unittest.TestCase):\n    def test_transform_cost_inventory_for_html_sorts_and_sums_costs(self):\n        months, cost_values, total_cost, currency_code, currency_symbol = (\n            transform_cost_inventory_for_html(\n                [\n                    {\"month\": \"2026-02-01\", \"cost\": 11.25, \"currency\": \"USD\"},\n                    {\"month\": \"2026-01-01\", \"cost\": 14.75, \"currency\": \"USD\"},\n                ]\n            )\n        )\n\n        self.assertEqual(months, [\"Jan\", \"Feb\"])\n        self.assertEqual(cost_values, [14.75, 11.25])\n        self.assertEqual(total_cost, 26.0)\n        self.assertEqual(currency_code, \"USD\")\n        self.assertEqual(currency_symbol, \"$\")\n\n    def test_transform_risk_inventory_for_html_counts_overall_and_resource_risks(self):\n        resource_inventory = {\n            \"101\": {\"name\": \"EC2 Instance\"},\n            \"202\": {\"name\": \"S3 Bucket\"},\n        }\n\n        risks, severity_counts = transform_risk_inventory_for_html(\n            build_risk_data(),\n            build_risk_definitions(),\n            resource_inventory,\n        )\n\n        self.assertEqual([risk[\"severity\"] for risk in risks], [\"high\", \"medium\"])\n        self.assertEqual(risks[0][\"impacted_resources_count\"], 2)\n        self.assertCountEqual(\n            risks[0][\"impacted_resources\"], [\"EC2 Instance\", \"S3 Bucket\"]\n        )\n        self.assertIsNone(risks[1][\"impacted_resources_count\"])\n        self.assertEqual(severity_counts, {\"high\": 1, \"medium\": 1, \"low\": 0})\n\n    def test_transform_alt_tech_for_html_filters_by_strategy_and_status(self):\n        transformed = transform_alt_tech_for_html(\n            build_resource_inventory(),\n            build_alternatives(),\n            build_alternative_technologies(),\n            exit_strategy=1,\n        )\n\n        self.assertEqual(len(transformed), 2)\n        self.assertEqual(transformed[0][\"product_name\"], \"OpenStack\")\n        self.assertEqual(transformed[1][\"product_name\"], \"MinIO\")\n        self.assertTrue(transformed[0][\"open_source\"])\n        self.assertFalse(transformed[1][\"support_plan\"])\n\n\nclass JsonTransformTests(unittest.TestCase):\n    def test_transform_resource_inventory_for_json_maps_names_and_codes(self):\n        transformed = transform_resource_inventory_for_json(\n            build_resource_inventory(),\n            build_resource_type_mapping(),\n        )\n\n        self.assertEqual(transformed[0][\"resource_name\"], \"EC2 Instance\")\n        self.assertEqual(\n            transformed[1][\"code\"],\n            \"AWS.S3.ListBuckets.Buckets\",\n        )\n\n    def test_transform_risk_inventory_for_json_maps_impacted_resource_ids(self):\n        transformed = transform_risk_inventory_for_json(\n            build_risk_data(),\n            build_risk_definitions(),\n            build_resource_inventory(),\n        )\n\n        transformed_by_id = {item[\"id\"]: item for item in transformed}\n        self.assertCountEqual(transformed_by_id[\"1\"][\"impacted_resources\"], [1, 2])\n        self.assertEqual(transformed_by_id[\"1\"][\"impacted_resources_count\"], 2)\n        self.assertIsNone(transformed_by_id[\"7\"][\"impacted_resources_count\"])\n\n    def test_transform_alt_tech_for_json_groups_by_resource_id(self):\n        transformed = transform_alt_tech_for_json(\n            build_resource_inventory(),\n            build_alternatives(),\n            build_alternative_technologies(),\n            exit_strategy=1,\n        )\n\n        self.assertEqual(list(transformed.keys()), [1, 2])\n        self.assertEqual(transformed[1][0][\"product_name\"], \"OpenStack\")\n        self.assertEqual(transformed[2][0][\"product_name\"], \"MinIO\")\n\n\nclass PdfTransformTests(unittest.TestCase):\n    def test_transform_cost_inventory_for_pdf_limits_to_last_six_months(self):\n        months, costs, currency_symbol = transform_cost_inventory_for_pdf(\n            [\n                {\"month\": \"2025-10-01\", \"cost\": 8.0, \"currency\": \"USD\"},\n                {\"month\": \"2025-11-01\", \"cost\": 10.5, \"currency\": \"USD\"},\n                {\"month\": \"2025-12-01\", \"cost\": 12.0, \"currency\": \"USD\"},\n                {\"month\": \"2026-01-01\", \"cost\": 14.75, \"currency\": \"USD\"},\n                {\"month\": \"2026-02-01\", \"cost\": 11.25, \"currency\": \"USD\"},\n                {\"month\": \"2026-03-01\", \"cost\": 9.0, \"currency\": \"USD\"},\n                {\"month\": \"2026-04-01\", \"cost\": 13.4, \"currency\": \"USD\"},\n            ]\n        )\n\n        self.assertEqual(months, [\"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\", \"Apr\"])\n        self.assertEqual(costs, [10.5, 12.0, 14.75, 11.25, 9.0, 13.4])\n        self.assertEqual(currency_symbol, \"$\")\n\n    def test_transform_risk_inventory_for_pdf_counts_resource_backed_risks(self):\n        risks, severity_counts = transform_risk_inventory_for_pdf(\n            build_risk_data(),\n            build_risk_definitions(),\n            build_resource_inventory(),\n        )\n\n        risks_by_name = {item[\"name\"]: item for item in risks}\n        self.assertEqual(\n            risks_by_name[\"Limited Alternatives\"][\"impacted_resources_count\"], 2\n        )\n        self.assertEqual(\n            risks_by_name[\"Large Service Footprint\"][\"impacted_resources_count\"], 0\n        )\n        self.assertEqual(severity_counts, {\"high\": 1, \"medium\": 1, \"low\": 0})\n\n    def test_transform_resource_inventory_for_pdf_builds_report_relative_icon_paths(\n        self,\n    ):\n        with tempfile.TemporaryDirectory() as report_dir:\n            transformed = transform_resource_inventory_for_pdf(\n                build_resource_inventory(),\n                build_resource_type_mapping(),\n                report_dir,\n            )\n\n        self.assertEqual(transformed[0][\"resource_name\"], \"EC2 Instance\")\n        self.assertTrue(\n            transformed[0][\"icon_url\"].endswith(\"/assets/icons/misc/no_image.png\")\n        )\n\n    def test_transform_alt_tech_for_pdf_counts_matching_alternatives(self):\n        with tempfile.TemporaryDirectory() as report_dir:\n            transformed = transform_alt_tech_for_pdf(\n                build_resource_inventory(),\n                build_resource_type_mapping(),\n                build_alternatives(),\n                build_alternative_technologies(),\n                exit_strategy=1,\n                report_path=report_dir,\n            )\n\n        self.assertEqual(transformed[0][\"count\"], 1)\n        self.assertEqual(transformed[1][\"count\"], 1)\n        self.assertTrue(\n            transformed[0][\"icon_url\"].endswith(\"/assets/icons/misc/no_image.png\")\n        )\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_utils_and_main.py",
    "content": "import json\nimport tempfile\nimport unittest\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport main\nfrom utils.utils import load_config\n\n\nclass LoadConfigTests(unittest.TestCase):\n    def test_load_config_returns_parsed_json(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config_path = Path(tmp_dir) / \"config.json\"\n            expected = {\n                \"cloudServiceProvider\": 2,\n                \"assessmentType\": 1,\n                \"providerDetails\": {\"region\": \"eu-central-1\"},\n            }\n            config_path.write_text(json.dumps(expected), encoding=\"utf-8\")\n\n            self.assertEqual(load_config(str(config_path)), expected)\n\n    def test_load_config_returns_none_for_missing_file(self):\n        with patch(\"utils.utils.console.print\") as mock_print:\n            result = load_config(\"/tmp/does-not-exist-config.json\")\n\n        self.assertIsNone(result)\n        mock_print.assert_called_once()\n\n    def test_load_config_returns_none_for_invalid_json(self):\n        with tempfile.TemporaryDirectory() as tmp_dir:\n            config_path = Path(tmp_dir) / \"config.json\"\n            config_path.write_text(\"{invalid json\", encoding=\"utf-8\")\n\n            with patch(\"utils.utils.console.print\") as mock_print:\n                result = load_config(str(config_path))\n\n        self.assertIsNone(result)\n        mock_print.assert_called_once()\n\n\nclass RunAssessmentPreValidationTests(unittest.TestCase):\n    def test_invalid_config_stops_before_pipeline_side_effects(self):\n        config = {\n            \"assessmentType\": 99,\n            \"cloudServiceProvider\": 2,\n            \"providerDetails\": {},\n        }\n\n        with (\n            patch(\n                \"main.validate_config\", side_effect=ValueError(\"bad config\")\n            ) as mock_validate,\n            patch(\"main.resolve_mode\") as mock_resolve_mode,\n            patch(\"main.create_directory\") as mock_create_directory,\n            patch(\"main.verify_credentials\") as mock_verify_credentials,\n            patch(\"main.print_step\") as mock_print_step,\n            patch(\"main.console.print\"),\n        ):\n            result = main.run_assessment(config, \"aws\")\n\n        self.assertIsNone(result)\n        mock_validate.assert_called_once_with(config)\n        mock_print_step.assert_called_once_with(\n            \"Configuration validation failed.\", status=\"error\", logs=\"bad config\"\n        )\n        mock_resolve_mode.assert_not_called()\n        mock_create_directory.assert_not_called()\n        mock_verify_credentials.assert_not_called()\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/test_validate.py",
    "content": "import unittest\n\nfrom utils.validate import validate_config, validate_region\n\n\ndef build_aws_config():\n    return {\n        \"name\": \"Example Assessment\",\n        \"assessmentType\": 1,\n        \"cloudServiceProvider\": 2,\n        \"exitStrategy\": 1,\n        \"providerDetails\": {\n            \"accessKey\": \"AKIA_TEST\",\n            \"secretKey\": \"SECRET_TEST\",\n            \"region\": \"eu-central-1\",\n        },\n    }\n\n\ndef build_azure_config():\n    return {\n        \"name\": \"Example Assessment\",\n        \"assessmentType\": 2,\n        \"cloudServiceProvider\": 1,\n        \"exitStrategy\": 3,\n        \"providerDetails\": {\n            \"tenantId\": \"tenant-id\",\n            \"clientId\": \"client-id\",\n            \"clientSecret\": \"client-secret\",\n            \"subscriptionId\": \"subscription-id\",\n            \"resourceGroupName\": \"resource-group\",\n        },\n    }\n\n\nclass ValidateRegionTests(unittest.TestCase):\n    def test_accepts_known_region(self):\n        self.assertIsNone(validate_region(\"eu-central-1\"))\n\n    def test_rejects_unknown_region(self):\n        with self.assertRaisesRegex(ValueError, \"Invalid AWS region\"):\n            validate_region(\"moon-central-1\")\n\n\nclass ValidateConfigTests(unittest.TestCase):\n    def test_accepts_valid_aws_config(self):\n        self.assertTrue(validate_config(build_aws_config()))\n\n    def test_accepts_valid_azure_service_principal_config(self):\n        self.assertTrue(validate_config(build_azure_config()))\n\n    def test_accepts_valid_azure_cli_config(self):\n        config = build_azure_config()\n        config[\"providerDetails\"] = {\n            \"credential\": object(),\n            \"tenantId\": \"tenant-id\",\n            \"subscriptionId\": \"subscription-id\",\n            \"resourceGroupName\": \"resource-group\",\n        }\n\n        self.assertTrue(validate_config(config))\n\n    def test_rejects_azure_config_without_client_credentials(self):\n        config = build_azure_config()\n        del config[\"providerDetails\"][\"clientId\"]\n        del config[\"providerDetails\"][\"clientSecret\"]\n\n        with self.assertRaisesRegex(\n            ValueError, \"Missing required fields in providerDetails\"\n        ):\n            validate_config(config)\n\n    def test_rejects_invalid_assessment_type(self):\n        config = build_aws_config()\n        config[\"assessmentType\"] = 9\n\n        with self.assertRaisesRegex(ValueError, \"Invalid assessmentType\"):\n            validate_config(config)\n\n    def test_rejects_non_integer_top_level_fields(self):\n        config = build_aws_config()\n        config[\"assessmentType\"] = \"basic\"\n\n        with self.assertRaisesRegex(ValueError, \"must be integers\"):\n            validate_config(config)\n\n    def test_rejects_invalid_name_characters(self):\n        config = build_aws_config()\n        config[\"name\"] = \"Bad/Name\"\n\n        with self.assertRaisesRegex(\n            ValueError, \"Assessment name contains invalid characters\"\n        ):\n            validate_config(config)\n\n    def test_rejects_too_long_name(self):\n        config = build_aws_config()\n        config[\"name\"] = \"a\" * 51\n\n        with self.assertRaisesRegex(ValueError, \"cannot exceed 50 characters\"):\n            validate_config(config)\n\n    def test_rejects_aws_config_with_invalid_region(self):\n        config = build_aws_config()\n        config[\"providerDetails\"][\"region\"] = \"invalid-region\"\n\n        with self.assertRaisesRegex(ValueError, \"Invalid AWS region\"):\n            validate_config(config)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "utils/aws.py",
    "content": "# utils/aws.py\nimport logging\nimport shutil\nimport subprocess\n\nlogger = logging.getLogger(\"main.utils.aws\")\n\n\ndef is_aws_cli_installed() -> bool:\n    return shutil.which(\"aws\") is not None\n\n\ndef is_aws_profile_valid(profile: str) -> bool:\n    try:\n        subprocess.run(\n            [\"aws\", \"configure\", \"list\", \"--profile\", profile],\n            check=True,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        return True\n    except subprocess.CalledProcessError:\n        return False\n"
  },
  {
    "path": "utils/azure.py",
    "content": "# utils/azure.py\nimport logging\nimport shutil\nimport subprocess\nfrom typing import List, Any\nfrom rich.console import Console\nfrom azure.identity import AzureCliCredential\nfrom azure.core.exceptions import ClientAuthenticationError\n\nlogger = logging.getLogger(\"main.utils.azure\")\nconsole = Console()\n\n\ndef is_azure_cli_installed() -> bool:\n    return shutil.which(\"az\") is not None\n\n\ndef is_azure_cli_logged_in() -> bool:\n    try:\n        # Run the 'az account show' command to check if the user is logged in\n        subprocess.run(\n            [\"az\", \"account\", \"show\"],\n            check=True,\n            stdout=subprocess.DEVNULL,\n            stderr=subprocess.DEVNULL,\n        )\n        return True\n    except subprocess.CalledProcessError:\n        return False\n\n\ndef is_azure_cli_token_expired() -> bool:\n    credential = AzureCliCredential()\n    try:\n        credential.get_token(\"https://management.azure.com/.default\")\n        return False  # Token is valid\n    except ClientAuthenticationError as e:\n        if \"AADSTS700082\" in str(e):\n            return True  # Token expired\n        return False\n\n\ndef select_subscription(subscriptions: List[Any]) -> Any:\n    # logger.info(\"Listing available subscriptions for selection.\")\n    console.print(\"Available Subscriptions:\")\n    for idx, sub in enumerate(subscriptions, start=1):\n        console.print(f\"{idx}. {sub.display_name} ({sub.subscription_id})\")\n    while True:\n        try:\n            selection = int(input(\"Select a subscription by number: \").strip())\n            if not (1 <= selection <= len(subscriptions)):\n                raise ValueError(\"Invalid subscription selection.\")\n            selected_subscription = subscriptions[selection - 1]\n            # logger.info(f\"Subscription selected: {selected_subscription.display_name} ({selected_subscription.subscription_id})\")\n            return selected_subscription\n        except ValueError as e:\n            logger.warning(f\"Invalid subscription selection: {e}\")\n            console.print(f\"[red]{e} Please select a valid number.[/red]\")\n\n\ndef select_resource_group(resource_groups: List[Any]) -> str:\n    # logger.info(\"Listing available resource groups for selection.\")\n    console.print(\"Available Resource Groups:\")\n    for idx, rg in enumerate(resource_groups, start=1):\n        console.print(f\"{idx}. {rg.name}\")\n    while True:\n        try:\n            selection = int(input(\"Select a resource group by number: \").strip())\n            if not (1 <= selection <= len(resource_groups)):\n                raise ValueError(\"Invalid resource group selection.\")\n            selected_resource_group = resource_groups[selection - 1].name\n            # logger.info(f\"Resource Group selected: {selected_resource_group}\")\n            return selected_resource_group\n        except ValueError as e:\n            logger.warning(f\"Invalid resource group selection: {e}\")\n            console.print(f\"[red]{e} Please select a valid number.[/red]\")\n"
  },
  {
    "path": "utils/connection.py",
    "content": "# utils/connection.py\nfrom __future__ import annotations\n\nimport logging\nimport requests\nfrom typing import Tuple, Optional\n\nlogger = logging.getLogger(\"main.utils.connection\")\n\ntry:\n    import config\nexcept ModuleNotFoundError:\n    config = None\n\n_AUTH_PATH = \"/api/v1/auth/token/\"\n\n\ndef _build_url(host: str) -> str:\n    host = host.strip().rstrip(\"/\")\n    if not host.startswith(\"http\"):\n        host = f\"https://{host}\"\n    return f\"{host}{_AUTH_PATH}\"\n\n\ndef get_jwt_token(\n    host: str | None = None, key: str | None = None, *, timeout: int = 10\n) -> Optional[str]:\n    host = host or getattr(config, \"HOST\", \"\") if config else \"\"\n    key = key or getattr(config, \"KEY\", \"\") if config else \"\"\n\n    if not host:\n        logger.debug(\"HOST empty – skipping ExitCloud authentication.\")\n        return None\n    if not key:\n        logger.debug(\"KEY empty – skipping ExitCloud authentication.\")\n        return None\n\n    url = _build_url(host)\n    headers = {\"Authorization\": f\"Bearer {key}\"}\n\n    try:\n        resp = requests.post(url, headers=headers, timeout=timeout)\n        resp.raise_for_status()\n        data = resp.json()\n\n        token = (\n            data.get(\"access_token\")\n            or data.get(\"token\")\n            or data.get(\"access\")\n            or data.get(\"jwt\")\n        )\n        if token:\n            return token\n\n        logger.error(\n            \"Authentication succeeded but token field missing in response: %s\", data\n        )\n    except requests.RequestException as exc:\n        logger.error(\"EscapeCloud authentication request failed: %s\", exc)\n    except ValueError:\n        logger.error(\"EscapeCloud authentication response was not valid JSON.\")\n\n    return None\n\n\ndef resolve_mode() -> Tuple[str, Optional[str]]:\n    host = getattr(config, \"HOST\", \"\") if config else \"\"\n    key = getattr(config, \"KEY\", \"\") if config else \"\"\n\n    if not host:\n        logger.debug(\"HOST empty – running in offline mode.\")\n        return \"offline\", None\n    if not key:\n        logger.debug(\"KEY empty – running in offline mode.\")\n        return \"offline\", None\n\n    token = get_jwt_token(host=host, key=key)\n    if token:\n        return \"online\", token\n\n    logger.debug(\"ExitCloud auth failed – falling back to offline mode.\")\n    return \"offline\", None\n"
  },
  {
    "path": "utils/constants.py",
    "content": "# utils/constants.py\nREGION_CHOICES = [\n    (\"us-east-1\", \"us-east-1 (N. Virginia)\"),\n    (\"us-east-2\", \"us-east-2 (Ohio)\"),\n    (\"us-west-1\", \"us-west-1 (N. California)\"),\n    (\"us-west-2\", \"us-west-2 (Oregon)\"),\n    (\"af-south-1\", \"af-south-1 (Cape Town)\"),\n    (\"ap-east-1\", \"ap-east-1 (Hong Kong)\"),\n    (\"ap-south-1\", \"ap-south-1 (Mumbai)\"),\n    (\"ap-northeast-1\", \"ap-northeast-1 (Tokyo)\"),\n    (\"ap-northeast-2\", \"ap-northeast-2 (Seoul)\"),\n    (\"ap-northeast-3\", \"ap-northeast-3 (Osaka)\"),\n    (\"ap-southeast-1\", \"ap-southeast-1 (Singapore)\"),\n    (\"ap-southeast-2\", \"ap-southeast-2 (Sydney)\"),\n    (\"ca-central-1\", \"ca-central-1 (Central)\"),\n    (\"eu-central-1\", \"eu-central-1 (Frankfurt)\"),\n    (\"eu-west-1\", \"eu-west-1 (Ireland)\"),\n    (\"eu-west-2\", \"eu-west-2 (London)\"),\n    (\"eu-west-3\", \"eu-west-3 (Paris)\"),\n    (\"eu-south-1\", \"eu-south-1 (Milan)\"),\n    (\"eu-north-1\", \"eu-north-1 (Stockholm)\"),\n    (\"me-south-1\", \"me-south-1 (Bahrain)\"),\n    (\"sa-east-1\", \"sa-east-1 (São Paulo)\"),\n]\n\nREQUIRED_FIELDS_AZURE = [\n    \"clientId\",\n    \"clientSecret\",\n    \"tenantId\",\n    \"subscriptionId\",\n    \"resourceGroupName\",\n]\nREQUIRED_FIELDS_AWS = [\"accessKey\", \"secretKey\", \"region\"]\n"
  },
  {
    "path": "utils/data.py",
    "content": "# utils/data.py\nimport os\nimport gzip\nimport shutil\nimport hashlib\nimport time\nimport requests\nfrom typing import Optional\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\nfrom requests.exceptions import RequestException, ConnectionError, Timeout\n\n# Constants\nDATASET_FOLDER = Path(\"datasets\")\nREMOTE_STORAGE_URL = \"https://cloudexit-oss-data-eu.fsn1.your-objectstorage.com\"\n\n\ndef get_monday_date() -> str:\n    now = datetime.utcnow()\n    monday = now - timedelta(days=now.weekday())\n\n    if now.weekday() == 0 and now.hour < 8:\n        last_monday = monday - timedelta(days=7)\n        return last_monday.strftime(\"cloudexit-%Y-%m-%d.db.gz\")\n    else:\n        return monday.strftime(\"cloudexit-%Y-%m-%d.db.gz\")\n\n\ndef compute_file_hash(filepath: str) -> str:\n    hash_sha256 = hashlib.sha256()\n    with open(filepath, \"rb\") as f:\n        for chunk in iter(lambda: f.read(4096), b\"\"):\n            hash_sha256.update(chunk)\n    return hash_sha256.hexdigest()\n\n\ndef download_file(url: str, destination: str, retries: int = 3, delay: int = 5) -> bool:\n    for attempt in range(retries):\n        try:\n            response = requests.get(url, stream=True, timeout=30)\n            response.raise_for_status()\n\n            with open(destination, \"wb\") as f:\n                shutil.copyfileobj(response.raw, f)\n\n            print(f\"[INFO] Download successful: {destination}\")\n            return True\n\n        except ConnectionError:\n            print(\n                f\"[ERROR] Connection failed while downloading {url}. Retrying ({attempt + 1}/{retries})...\"\n            )\n        except Timeout:\n            print(\n                f\"[ERROR] Request timed out while downloading {url}. Retrying ({attempt + 1}/{retries})...\"\n            )\n        except RequestException as e:\n            print(f\"[ERROR] Failed to download {url}: {e}\")\n            break\n\n        time.sleep(delay)\n\n    print(f\"[ERROR] Unable to download file after {retries} attempts: {url}\")\n    return False\n\n\ndef fetch_remote_checksum(\n    checksum_url: str, retries: int = 3, delay: int = 5\n) -> Optional[str]:\n    for attempt in range(retries):\n        try:\n            response = requests.get(checksum_url, timeout=10)\n            response.raise_for_status()\n            return response.text.strip().split()[0]\n\n        except ConnectionError:\n            print(\n                f\"[ERROR] Connection failed when fetching {checksum_url}. Retrying ({attempt + 1}/{retries})...\"\n            )\n        except Timeout:\n            print(\n                f\"[ERROR] Request timed out when fetching {checksum_url}. Retrying ({attempt + 1}/{retries})...\"\n            )\n        except RequestException as e:\n            print(f\"[ERROR] Failed to fetch {checksum_url}: {e}\")\n            break\n\n        time.sleep(delay)\n\n    print(f\"[ERROR] Unable to fetch remote checksum after {retries} attempts.\")\n    return None\n\n\ndef initialize_dataset() -> None:\n    DATASET_FOLDER.mkdir(exist_ok=True)\n\n    latest_file = get_monday_date()\n    latest_file_url = f\"{REMOTE_STORAGE_URL}/{latest_file}\"\n    latest_checksum_url = f\"{REMOTE_STORAGE_URL}/{latest_file}.sha256\"\n    latest_symlink_file = f\"{REMOTE_STORAGE_URL}/cloudexit-latest.db.gz\"\n    latest_symlink_checksum_url = f\"{REMOTE_STORAGE_URL}/cloudexit-latest.db.gz.sha256\"\n\n    local_db_path = DATASET_FOLDER / \"data.db\"\n    local_compressed_path = DATASET_FOLDER / latest_file\n\n    # Fetch checksum for the date-based file\n    remote_checksum = fetch_remote_checksum(latest_checksum_url)\n    if not remote_checksum:\n        print(f\"[INFO] Unable to fetch remote checksum from {latest_checksum_url}.\")\n        print(f\"[INFO] Trying latest symlink from {latest_symlink_checksum_url}...\")\n        remote_checksum = fetch_remote_checksum(latest_symlink_checksum_url)\n        latest_file_url = latest_symlink_file\n        latest_file = \"cloudexit-latest.db.gz\"\n        local_compressed_path = DATASET_FOLDER / latest_file\n\n    if not remote_checksum:\n        print(\"[ERROR] Unable to fetch any remote checksum. Skipping update.\")\n\n    else:\n        # Check if local compressed file exists\n        if local_compressed_path.exists():\n            local_checksum = compute_file_hash(local_compressed_path)\n            if local_checksum == remote_checksum:\n                print(\"[INFO] Local dataset is up-to-date. No download needed.\")\n                return\n            else:\n                print(\n                    \"[INFO] Local dataset is outdated. Removing old files and downloading new dataset...\"\n                )\n\n                # Remove all old compressed and extracted files\n                for file in DATASET_FOLDER.glob(\"cloudexit-*.db.gz\"):\n                    os.remove(file)\n                if local_db_path.exists():\n                    os.remove(local_db_path)\n\n        # Download and extract dataset\n        if download_file(latest_file_url, local_compressed_path):\n            print(\n                f\"[INFO] Download successful. Extracting dataset from {latest_file}...\"\n            )\n\n            with gzip.open(local_compressed_path, \"rb\") as f_in, open(\n                local_db_path, \"wb\"\n            ) as f_out:\n                shutil.copyfileobj(f_in, f_out)\n\n            print(\"[INFO] Dataset updated successfully.\")\n\n    if not any(DATASET_FOLDER.iterdir()):\n        print(\"[ERROR] Dataset folder is empty! Cannot proceed without data.\")\n        exit(1)\n"
  },
  {
    "path": "utils/sync.py",
    "content": "# utils/sync.py\nfrom __future__ import annotations\n\nimport logging\nimport requests\nimport config\nfrom typing import Optional, Dict, Any\nfrom utils.auth import get_jwt_token\n\nlogger = logging.getLogger(\"main.utils.sync\")\n\n_ASSESS_PATH = \"/api/v1/assessments/\"\n\n\ndef _build_url(host: str) -> str:\n    host = host.strip().rstrip(\"/\")\n    if not host.startswith(\"http\"):\n        host = f\"https://{host}\"\n    return f\"{host}{_ASSESS_PATH}\"\n\n\ndef submit_assessment(\n    payload: Dict[str, Any],\n    *,\n    host: str | None = None,\n    key: str | None = None,\n    timeout: int = 10,\n) -> Optional[requests.Response]:\n    host = host or getattr(config, \"HOST\", \"\") if config else \"\"\n    if not host:\n        logger.warning(\"HOST not configured – skipping assessment sync.\")\n        return None\n\n    token = get_jwt_token(host=host, key=key) if key else get_jwt_token(host=host)\n    if not token:\n        logger.warning(\"Could not obtain JWT – skipping assessment sync.\")\n        return None\n\n    url = _build_url(host)\n    headers = {\n        \"Authorization\": f\"Bearer {token}\",\n        \"Content-Type\": \"application/json\",\n    }\n\n    try:\n        resp = requests.post(url, headers=headers, json=payload, timeout=timeout)\n        logger.info(\"POST %s – status %s\", url, resp.status_code)\n        return resp\n    except requests.RequestException as exc:\n        logger.error(\"Assessment POST failed: %s\", exc)\n        return None\n"
  },
  {
    "path": "utils/utils.py",
    "content": "# utils/utils.py\nimport os\nimport logging\nimport json\nfrom typing import Optional, Tuple, Dict, Any\nfrom rich.console import Console\nfrom rich.style import Style\nfrom time import sleep\nfrom datetime import datetime\n\nlogger = logging.getLogger(\"main.utils\")\nconsole = Console()\n\n\ndef load_config(file_path: str) -> Optional[Dict[str, Any]]:\n    try:\n        # logger.info(f\"Attempting to load config file from {file_path}\")\n        with open(file_path, \"r\") as f:\n            config = json.load(f)\n        # logger.info(\"Config file loaded successfully.\")\n        return config\n    except Exception as e:\n        logger.error(f\"Error loading config file: {e}\", exc_info=True)\n        console.print(f\"[red]Error loading config file: {e}[/red]\")\n        return None\n\n\ndef prompt_required_inputs() -> Tuple[int, int]:\n    while True:\n        try:\n            exit_strategy = int(\n                input(\n                    \"Enter Exit Strategy (1 for 'Repatriation to On-Premises', 3 for 'Migration to Alternate Cloud'): \"\n                ).strip()\n            )\n            if exit_strategy not in [1, 3]:\n                raise ValueError(\"Invalid exit strategy.\")\n            # logger.info(f\"Exit Strategy selected: {exit_strategy}\")\n            break\n        except ValueError as e:\n            logger.warning(f\"Invalid exit strategy input: {e}\")\n            console.print(f\"[red]{e} Please enter 1 or 3.[/red]\")\n\n    while True:\n        try:\n            assessment_type = int(\n                input(\n                    \"Enter Assessment Type (1 for 'Basic', 2 for 'Standard'): \"\n                ).strip()\n            )\n            if assessment_type not in [1, 2]:\n                raise ValueError(\"Invalid assessment type.\")\n            # logger.info(f\"Assessment Type selected: {assessment_type}\")\n            break\n        except ValueError as e:\n            logger.warning(f\"Invalid assessment type input: {e}\")\n            console.print(f\"[red]{e} Please enter 1 or 2.[/red]\")\n\n    return exit_strategy, assessment_type\n\n\ndef print_step(\n    description: str, status: str = \"pending\", logs: Optional[str] = None\n) -> None:\n    # Define styles for statuses\n    ok_style = Style(color=\"green\", bold=True)\n    error_style = Style(color=\"red\", bold=True)\n    warning_style = Style(color=\"yellow\", bold=True)\n    # Map statuses to their visual representation\n    status_map = {\n        \"ok\": \"[ ok ]\",\n        \"error\": \"[ error ]\",\n        \"warning\": \"[ warn ]\",\n        \"pending\": \"[ ... ]\",\n    }\n\n    # Handle the pending status with a spinner\n    if status == \"pending\":\n        with console.status(\n            f\"{description:<50} [yellow]{status_map['pending']}[/yellow]\",\n            spinner=\"dots\",\n        ):\n            sleep(2)\n            print_step(description, status=\"ok\")\n    elif status == \"ok\":\n        console.print(f\"{description:<50} {status_map['ok']}\", style=ok_style)\n    elif status == \"warning\":\n        console.print(f\"{description:<50} {status_map['warning']}\", style=warning_style)\n        if logs:\n            console.print(f\"   ↳ {logs}\", style=\"dim\")\n    elif status == \"error\":\n        console.print(f\"{description:<50} {status_map['error']}\", style=error_style)\n        if logs:\n            console.print(f\"   ↳ {logs}\", style=\"dim\")\n\n\nascii_art = r\"\"\"\n      _                 _           _ _\n     | |               | |         (_) |\n  ___| | ___  _   _  __| | _____  ___| |_\n / __| |/ _ \\| | | |/ _` |/ _ \\ \\/ / | __|\n| (__| | (_) | |_| | (_| |  __/>  <| | |_\n \\___|_|\\___/ \\__,_|\\__,_|\\___/_/\\_\\_|\\__|\n\n\n\"\"\"\n\n\ndef create_directory(base_path=\"reports\"):\n    # Generate the main directory with a timestamp\n    timestamp = datetime.now().strftime(\"%Y%m%d%H%M%S\")\n    directory_path = os.path.join(base_path, timestamp)\n\n    # Create the main directory\n    os.makedirs(directory_path, exist_ok=True)\n\n    # Create the raw_data subdirectory within the main directory\n    raw_data_path = os.path.join(directory_path, \"raw_data\")\n    os.makedirs(raw_data_path, exist_ok=True)\n\n    return directory_path, raw_data_path\n\n\ndef print_help_message():\n    console.print(\"EscapeCloud - Community Edition\", style=\"bold cyan\")\n    console.print(\"[green]Run the script with one of the following options:[/green]\\n\")\n    console.print(\"  python3 main.py aws\")\n    console.print(\"  python3 main.py aws --config config/aws.json\")\n    console.print(\"  python3 main.py aws --profile PROFILE\")\n    console.print(\"  python3 main.py aws --name 'DMS System' \")\n    console.print(\"  python3 main.py azure\")\n    console.print(\"  python3 main.py azure --config config/azure.json\")\n    console.print(\"  python3 main.py azure --cli\")\n    console.print(\"  python3 main.py azure --name 'DMS System'\")\n"
  },
  {
    "path": "utils/validate.py",
    "content": "# utils/validate.py\nfrom typing import Dict, Any\nfrom .constants import REGION_CHOICES, REQUIRED_FIELDS_AZURE, REQUIRED_FIELDS_AWS\n\n\ndef validate_region(region: str) -> None:\n    valid_regions = [choice[0] for choice in REGION_CHOICES]\n    if region not in valid_regions:\n        raise ValueError(f\"Invalid AWS region. Choose from: {', '.join(valid_regions)}\")\n\n\ndef validate_config(config: Dict[str, Any]) -> bool:\n    try:\n        # Cast key values to integers to handle string input gracefully\n        assessment_type = int(config.get(\"assessmentType\", 0))\n        cloud_service_provider = int(config.get(\"cloudServiceProvider\", 0))\n        exit_strategy = int(config.get(\"exitStrategy\", 0))\n    except ValueError:\n        raise ValueError(\n            \"Invalid input: assessmentType, cloudServiceProvider, and exitStrategy must be integers.\"\n        )\n\n    # Validate assessmentType\n    if assessment_type not in [1, 2]:\n        raise ValueError(\"Invalid assessmentType. Must be 1 (Basic) or 2 (Standard).\")\n\n    # Validate cloudServiceProvider\n    if cloud_service_provider not in [1, 2]:\n        raise ValueError(\"Invalid cloudServiceProvider. Must be 1 (Azure) or 2 (AWS).\")\n\n    # Validate exitStrategy\n    if exit_strategy not in [1, 2, 3]:\n        raise ValueError(\n            \"Invalid exitStrategy. Must be 1 (Repatriation to On-Premises), 2 (Hybrid Cloud Adoption) or 3 (Migration to Alternate Cloud).\"\n        )\n\n    # Validate name\n    name = config.get(\"name\", \"\").strip()\n    if len(name) > 50:\n        raise ValueError(\"Assessment name cannot exceed 50 characters.\")\n    if not all(c.isalnum() or c in \" ._-()\" for c in name):\n        raise ValueError(\n            \"Assessment name contains invalid characters. Only letters, numbers, spaces, . _ - ( ) are allowed.\"\n        )\n\n    # Validate providerDetails based on cloudServiceProvider\n    provider_details = config.get(\"providerDetails\", {})\n    if cloud_service_provider == 1:  # Azure\n        # Skip validation of clientId and clientSecret if using CLI credentials\n        if provider_details.get(\"credential\") is not None:\n            required_fields = [\"tenantId\", \"subscriptionId\", \"resourceGroupName\"]\n        else:\n            required_fields = REQUIRED_FIELDS_AZURE\n        missing_fields = [\n            field for field in required_fields if field not in provider_details\n        ]\n    elif cloud_service_provider == 2:  # AWS\n        missing_fields = [\n            field for field in REQUIRED_FIELDS_AWS if field not in provider_details\n        ]\n        if \"region\" in provider_details:\n            validate_region(provider_details[\"region\"])\n    else:\n        raise ValueError(\n            f\"Invalid cloudServiceProvider: {cloud_service_provider}. Supported values: 1 (Azure), 2 (AWS).\"\n        )\n\n    if missing_fields:\n        raise ValueError(\n            f\"Missing required fields in providerDetails: {', '.join(missing_fields)}\"\n        )\n\n    return True\n"
  }
]