#
#  Copyright (c) 2022-2025 Tomasz Habiger and contributors
#

import os
import json
import logging
import hcl
import hcl2
import re
from lark import UnexpectedToken, UnexpectedCharacters
from .objpath_helper import ObjectPath
from .process import Process as TerragruntProcess
from .s3 import S3

logger = logging.getLogger(__name__)

class State:
    def __init__(self, path=None, path_limit='/', config="root.hcl", key_prefix=None, key_filename='terraform.tfstate', state_as_optree=True):
        self.path = path if path else os.getcwd()
        self.path_limit = path_limit

        self.tfstate_config = config
        self.tfstate_key_prefix = key_prefix
        self.tfstate_key_filename = key_filename

        self.tg = TerragruntProcess(cwd=self.path, cmd="render --format=json")

        self.data_as_optree = state_as_optree
        self.data = self.load()

    def _builtin_hcl_loads(self, content=None):
        rv = None

        skippable_exceptions = (
            UnexpectedToken,
            UnexpectedCharacters,
            UnicodeDecodeError
        )

        config_data = re.sub(r"\s+\?", " ?", content, re.MULTILINE)
        for parser in [hcl2.loads, hcl.loads]:
            try:
                logging.debug(f"trying parser {parser.__module__}.{parser.__name__}")
                rv = parser(config_data)
            except skippable_exceptions:
                if rv is not None:
                    logger.debug("skippable exception occurred while parsing HCL content, returning parsed data")
                    break
                continue
            except Exception as e:
                if rv is not None:
                    logger.warning(f"Unskippable exception occurred while parsing HCL content: {e}; HCL content has "
                                   f"been parsed, but some data may be incomplete")
                    break
                else:
                    logger.error(f"Unskippable exception occurred while parsing HCL content: {e}; HCL content could "
                                 f"not be parsed with parser {parser.__module__}.{parser.__name__}")

        return rv

    def _builtin_hcl_load(self, file=''):
        rv = None

        logger.debug(f"loading HCL file: {file}")
        with open(file) as f:
            rv = self._builtin_hcl_loads(f.read())

        return rv

    def _builtin_search_file(self, fname=""):
        cp = os.path.abspath(self.path)
        relpath = []

        logger.debug(f"searching for file: {fname}")
        for d in list(filter(None, list(reversed(cp.removeprefix(self.path_limit).split('/'))))):
            relpath.insert(0, d)

            cp = cp.removesuffix("/{}".format(d))
            fpath = "{}/{}".format(cp, fname)

            logger.debug(f"checking path: {fpath}")
            if os.path.exists(fpath) and os.path.isfile(fpath) and os.access(fpath, os.F_OK | os.R_OK):
                return fpath, '/'.join(relpath)

        return None, None

    def _builtin_try_render(self, config=None):
        rv = None
        config_created = False

        cfg = f"{self.path}/terragrunt.hcl"

        if self.tg.version >= (0, 77, 18):
            try:
                if not os.path.exists(cfg) or not os.path.isfile(cfg):
                    with open(cfg, "w") as f:
                        f.write(f"include \"root\" {{\n  path = find_in_parent_folders(\"{config}\")\n}}\n")

                    config_created = True
                    logger.debug(f"rendered temporal configuration: {cfg} (with included {config})")
                else:
                    logger.debug(f"{cfg} already exists, no need for temporal configuration")

                self.tg.exec(live=False)

                try:
                    config_data = json.loads(self.tg.output.stdout)
                except json.JSONDecodeError as e:
                    logger.debug(f"parsing rendered JSON failed: {e} (possibly due missing or invalid included "
                                 f"configuration file: {config})")
                    return rv
            finally:
                if config_created and os.path.exists(cfg) and os.path.isfile(cfg):
                    os.remove(cfg)
                    logger.debug(f"removed temporal configuration: {cfg}")

            if config_data:
                if tmp := config_data.get('remote_state', None).get('config', None):
                    rv = {
                        'bucket': tmp['bucket'],
                        'key': tmp['key']
                    }
                    logger.debug(f"got OpenTofu/Terraform state location: {rv}")
                else:
                    logger.error(f"Missing required state configuration block in Terragrunt's rendered settings")
        else:
            logger.debug("cannot use 'terragrunt render': terragrunt version {} < 0.77.18".format('.'.join(map(str, self.tg.version))))

        return rv

    def _builtin_try_search(self, config=None):
        rv = None
        config_data = None

        tfstate_config, tfstate_key_relpath = self._builtin_search_file(config)
        if tfstate_config:
            try:
                config_data = self._builtin_hcl_load(tfstate_config)
            except Exception as e:
                logger.warning(f"Cannot parse Terragrunt config file {tfstate_config}: {e}")

            if config_data:
                tmp = ObjectPath.query(config_data, "$..remote_state..config")
                if tmp:
                    rv = {
                        'bucket': tmp[0]['bucket'],
                        'key': '/'.join(filter(None, [self.tfstate_key_prefix, tfstate_key_relpath, self.tfstate_key_filename]))
                    }
                    logger.debug(f"got (calculated) OpenTofu/Terraform state location: {rv}")
                else:
                    logger.debug(f"cannot get (calculated) OpenTofu/Terraform state location")

        return rv

    def load(self):
        rv = None
        tfstate_data = []
        tfstate_json = None

        cfg_list = [
            "root.hcl",
            "terragrunt.hcl"
        ]

        if self.tfstate_config not in cfg_list:
            cfg_list.insert(0, self.tfstate_config)

        for f in cfg_list:
            logger.debug("(mode:render) attempting to get state configuration via rendered Terragrunt configuration")
            rv = self._builtin_try_render(f)
            if rv:
                logger.debug("(mode:render) extracted state configuration from rendered content")
                break

        if rv is None:
            cfg_list.insert(1, "terraform.tfvars")
            for f in cfg_list:
                logger.debug(f"(mode:search) checking for terragrunt's configuration: {f}")
                rv = self._builtin_try_search(f)
                if rv:
                    logger.debug(f"(mode:search) extracted state configuration from file {f}")
                    break

        if rv is None:
            logger.error("Cannot extract any information about OpenTofu/Terraform state location")
        else:
            tfstate_json = S3.get(rv['bucket'], rv['key'])
            if tfstate_json:
                rv = json.loads(tfstate_json)
                if self.data_as_optree:
                    tfstate_data.append(rv)
                    rv = ObjectPath.load(tfstate_data)
            else:
                logger.error(f"Couldn't load OpenTofu/Terraform state from S3 location s3://{rv['bucket']}/{rv['key']}")
                rv = None

        return rv

    def query(self, q=None):
        return ObjectPath.query(obj=self.data, q=q)

    def get_resources(self, type_name, id_name=None):
        """
        Get list of resource IDs

        :param type_name: string, ie. "aws_instance", "aws_autoscaling_group", "aws_db_instance", etc.
        :param id_name: ID attribute identificator for a resource. Most often it is just "id", but
            there are resources like "aws_spot_instance_request", where ID refers to a request ID
            and not an instance ID. If we'd like to get an instance ID, we have to provide alternative
            id_name, like "spot_instance_id" in this case.
        :return: list of resource IDs, ie. for EC2: ('i-08bf03c667b7eab43', 'i-8bg023c6d66bheac42', ...)
        """

        id_name = id_name if id_name else 'id'

        try:
            if re.match(r'0.11.[0-9]+', self.data.execute("$..terraform_version[0]")):
                return tuple(
                    self.data.execute("$..modules.resources..*[@.type is '{}'].primary.{}".format(
                        type_name, id_name
                    ))
                )
            else:
                return tuple(
                    self.data.execute("$..resources[@.type is '{}']..instances.attributes.{}".format(
                        type_name, id_name
                    ))
                )
        except AttributeError:
            return None

    def is_empty(self):
        return False if self.data else True
