"""Runs dbt parse in Fusion to identify parse erors in packages."""

import json
import os
import re
import subprocess
import tarfile
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Optional

import requests
from dbtlabs.proto.public.v1.events.fusion.invocation.invocation_pb2 import Invocation
from dbtlabs.proto.public.v1.events.fusion.log.log_pb2 import LogMessage
from google.protobuf import json_format
from rich.console import Console

from dbt_fusion_package_tools.compatibility import (
    FusionConformanceResult,
    FusionLogMessage,
    ParseConformanceLogOutput,
)
from dbt_fusion_package_tools.dbt_package_version import DbtPackageVersion
from dbt_fusion_package_tools.exceptions import FusionBinaryNotAvailable
from dbt_fusion_package_tools.yaml.loader import safe_load

console = Console()
error_console = Console(stderr=True)

"""Cleans model paths displayed in Fusion error messages.

Example:
    Original: ../../../../../../private/var/folders/xf/r3bz6sxx0m57976q8nb2d1fh0000gp/T/tmp0bnpllg5/models/examples/basic_example/basic_example_d_site.yml:10:9
    Cleaned: models/examples/basic_example/basic_example_d_site.yml:10:9
"""
_ERROR_PATH_REGEX = re.compile(r"(?:\.\./)*")


def construct_download_url_from_latest(
    latest_package_version_download_url: str, target_version_download_url: str
) -> str:
    version_download_tag: str = target_version_download_url.split("/")[-1]
    latest_version_url: list[str] = latest_package_version_download_url.split("/")[:-1]
    latest_version_url.append(version_download_tag)
    return "/".join(latest_version_url)


def download_tarball_and_run_conformance(
    package_name: str,
    package_id: str,
    package_version_str: str,
    package_version_download_url: str,
    latest_package_version_download_url: Optional[str],
    fusion_binary: Optional[str] = None,
) -> Optional[FusionConformanceResult]:
    with TemporaryDirectory() as tmpdir:
        # download tarball from version json
        tar_path: Optional[Path] = None
        # track exceptions across both checks
        exceptions: list[str] = []
        # use explicitly provided version first
        try:
            # Download the tarball
            response = requests.get(package_version_download_url, stream=True)
            response.raise_for_status()

            # Save to a temporary file
            tar_path = Path(tmpdir) / "archive.tar.gz"
            with open(tar_path, "wb") as f:
                for chunk in response.iter_content(chunk_size=8192):
                    if chunk:
                        f.write(chunk)
        except requests.exceptions.HTTPError as http_error:
            exceptions.append(
                f"{http_error.request.url}: {http_error.response.status_code}, {http_error.response.reason}"
            )
        except Exception as other_error:
            exceptions.append(f"Error when downloading tarball: {other_error}")
        # if that errors or doesn't exist, construct from the latest version
        if not tar_path and latest_package_version_download_url:
            constructed_url: str = construct_download_url_from_latest(
                latest_package_version_download_url, package_version_download_url
            )
            try:
                # Download the tarball
                response = requests.get(constructed_url, stream=True)
                response.raise_for_status()

                # Save to a temporary file
                tar_path = Path(tmpdir) / "archive.tar.gz"
                with open(tar_path, "wb") as f:
                    for chunk in response.iter_content(chunk_size=8192):
                        if chunk:
                            f.write(chunk)
            except requests.exceptions.HTTPError as http_error:
                exceptions.append(
                    f"{http_error.request.url}: {http_error.response.status_code}, {http_error.response.reason}"
                )
            except Exception as other_error:
                exceptions.append(f"Error when downloading tarball: {other_error}")
        # if still no download, error
        if not tar_path:
            console.log(f"Could not download {package_name} {package_version_str}")
            for exception in exceptions:
                console.log(exception)
            return FusionConformanceResult(version=package_version_str, download_failed=True)

        # if we do have a file, extract the archive
        try:
            extract_dir = Path(tmpdir) / "extracted"
            extract_dir.mkdir(parents=True, exist_ok=True)

            with tarfile.open(tar_path, "r:gz") as tar:
                for entry in tar:
                    if os.path.isabs(entry.name) or ".." in entry.name:
                        raise ValueError("Illegal tar archive entry")
                    tar.extract(entry, extract_dir)

            # Clean up the tar file
            tar_path.unlink()

            # Check that only 1 directory is inside
            tar_contents = os.listdir(extract_dir)
            if len(tar_contents) != 1:
                console.log("Error downloading tar")
            extracted_package = extract_dir / tar_contents[0]
        except Exception as e:
            console.log(f"Error when extracting tarball: {e}")
            return

        # run conformance if possible
        try:
            console.log(f"Running parse conformance for {package_id} version {package_version_str}")
            return run_conformance_for_version(
                extracted_package, package_name, package_version_str, package_id, fusion_binary=fusion_binary
            )
        except Exception as e:
            console.log(f"Error when running conformance: {e}")
            return


def run_conformance_for_version(
    path, package_name, tag_version, package_id, fusion_binary=None
) -> Optional[FusionConformanceResult]:
    result = FusionConformanceResult(version=tag_version, download_failed=False)
    # check require dbt version
    try:
        dbt_project_yml = safe_load((Path(f"{path}/dbt_project.yml")).read_text()) or (
            {},
            {},
        )
        require_dbt_version_string = dbt_project_yml[1].get("require-dbt-version")
    except Exception as e:
        error_console.log(f"dbt_project.yml load failed for {package_id} {tag_version}: {e}")
        return
    # try to add profile to suppress warning about profiles
    if "profile" not in dbt_project_yml[1]:
        console.log("Adding profile to dbt project")
        try:
            with open(Path(f"{path}/dbt_project.yml"), "a") as f:
                f.write("\nprofile: test_schema_compat\n")
        except Exception as e:
            error_console.log(f"failed when adding profile to dbt_project.yml for {package_id} {tag_version}: {e}")
    new_version: DbtPackageVersion = DbtPackageVersion(
        package_name,
        tag_version,
        package_id=package_id,
        raw_require_dbt_version_range=require_dbt_version_string,
    )
    parse_conformance = check_fusion_schema_compatibility(
        Path(path), fusion_binary=fusion_binary, show_fusion_output=True
    )
    result.require_dbt_version_defined = new_version.is_require_dbt_version_defined()
    if result.require_dbt_version_defined:
        result.require_dbt_version_compatible = new_version.is_require_dbt_version_fusion_compatible()
    result.manually_verified_compatible = new_version.is_explicitly_allowed_on_fusion()
    result.manually_verified_incompatible = new_version.is_explicitly_disallowed_on_fusion()
    if parse_conformance:
        result.parse_compatible = parse_conformance.parse_exit_code == 0
        result.parse_compatibility_result = parse_conformance
    return result


def check_binary_name(binary_name: str) -> bool:
    try:
        subprocess.run(
            [
                binary_name,
                "--version",
            ],
            capture_output=True,
            timeout=60,
            check=True,
        )
        return True
    # indicates that the binary name is not found
    except FileNotFoundError:
        return False
    # indicates that an error occurred when running command
    except subprocess.CalledProcessError as process_error:
        error_console.log(
            f"CalledProcessError: {binary_name} --version exited with return code {process_error.returncode}"
        )
        error_console.log(process_error.stderr)
        return False
    except Exception as other_error:
        error_console.log(f"{other_error}: An unknown exception occured when running {binary_name} --version")
        return False


def check_fusion_version(binary_name: str) -> Optional[str]:
    try:
        version_result = subprocess.run(
            [
                binary_name,
                "--version",
            ],
            check=False,
            capture_output=True,
            timeout=60,
            text=True,
        )
        if "dbt-fusion" in version_result.stdout:
            return version_result.stdout.split()[-1]
    except Exception as e:
        error_console.log(f"{e}: An unknown error occurred when checking dbt version")
    return


def find_fusion_binary(custom_name: Optional[str] = None) -> Optional[str]:
    possible_binary_names: list[str] = ["dbtf", "dbt"] if custom_name is None else [custom_name]

    binary_names_found: set[str] = set()
    # test each name
    for binary_name in possible_binary_names:
        # first check if name exists at all
        binary_exists = check_binary_name(binary_name)
        if binary_exists:
            binary_names_found.add(binary_name)

    if len(binary_names_found) == 0:
        error_console.log("No fusion binaries found on system path, please install first")
        return None

    # now check version returned by each and use first one
    # don't need exception handling here because previous step already did it
    for valid_binary_name in binary_names_found:
        version_result = check_fusion_version(valid_binary_name)
        if version_result:
            return valid_binary_name

    # if we got to the end, then no fusion version has been found
    error_console.log(f"Could not find Fusion binary, latest version output is {version_result}")
    return None


def parse_log_output(
    output: str, exit_code: int, repo_path: Path = Path.cwd(), fusion_version: str = "unknown"
) -> ParseConformanceLogOutput:
    log_output = [json.loads(x) for x in output.splitlines()]
    result = ParseConformanceLogOutput(parse_exit_code=exit_code, fusion_version=fusion_version)
    temp_path = str(repo_path)
    for line in log_output:
        if line.get("event_type") == "v1.public.events.fusion.log.LogMessage":
            severity_text = line.get("severity_text")
            # remove the tempdir path from the log message
            body_no_path = (str(line.get("body"))).replace(temp_path, "")
            body_no_path = body_no_path.replace("private/", "")
            body = _ERROR_PATH_REGEX.sub("", body_no_path)
            log_message = LogMessage()
            json_format.ParseDict(line.get("attributes"), log_message, ignore_unknown_fields=True)
            fusion_log_message = FusionLogMessage(
                body=body,
                severity_text=str(severity_text),
                error_code=log_message.code,
                original_severity_text=log_message.original_severity_text,
            )
            if severity_text == "ERROR":
                result.errors.append(fusion_log_message)
            elif severity_text == "WARNING":
                result.warnings.append(fusion_log_message)
        elif (
            line.get("record_type") == "SpanEnd"
            and line.get("event_type") == "v1.public.events.fusion.invocation.Invocation"
        ):
            invocation = Invocation()
            json_format.ParseDict(line.get("attributes"), invocation, ignore_unknown_fields=True)
            result.total_errors += invocation.metrics.total_errors
            result.total_warnings += invocation.metrics.total_warnings

    return result


def check_fusion_schema_compatibility(
    repo_path: Path = Path.cwd(),
    fusion_binary: Optional[str] = None,
    show_fusion_output=True,
) -> Optional[ParseConformanceLogOutput]:
    """Check if a dbt package is fusion schema compatible by running 'dbtf parse'.

    Args:
        fusion_binary_name: name of a valid Fusion binary
        repo_path: Path to the dbt package repository

    Returns:
        True if fusion compatible (dbtf parse exits with code 0), False otherwise
    """
    # Add a test profiles.yml to the current directory
    profiles_path = repo_path / Path("profiles.yml")
    try:
        with open(profiles_path, "a") as f:
            f.write(
                "\n"
                "test_schema_compat:\n"
                "  target: dev\n"
                "  outputs:\n"
                "    dev:\n"
                "      type: postgres\n"
                "      host: localhost\n"
                "      port: 5432\n"
                "      user: postgres\n"
                "      password: postgres\n"
                "      dbname: postgres\n"
                "      schema: public\n"
            )

        # Ensure the `_DBT_FUSION_STRICT_MODE` is set (this will ensure fusion errors on schema violations)
        os.environ["_DBT_FUSION_STRICT_MODE"] = "1"

        # Find correct name for Fusion binary if none provided
        fusion_binary_name: Optional[str] = fusion_binary
        if fusion_binary_name is None:
            fusion_binary_name = find_fusion_binary()
        # If still no valid name, return
        if fusion_binary_name is None:
            raise FusionBinaryNotAvailable()

        # Get the Fusion version
        fusion_version: Optional[str] = check_fusion_version(fusion_binary_name)
        if fusion_version is None:
            raise FusionBinaryNotAvailable()

        try:
            # Run dbt deps to install package dependencies
            if show_fusion_output:
                console.log("\n\nRunning dbt deps", style="green")
            deps_result = subprocess.run(
                [
                    fusion_binary_name,
                    "deps",
                    "--profile",
                    "test_schema_compat",
                    "--project-dir",
                    str(repo_path),
                    "--log-format",
                    "otel",
                ],
                check=False,
                text=True,
                capture_output=True,
                timeout=60,
            )
            deps_output = parse_log_output(
                deps_result.stdout, deps_result.returncode, repo_path, fusion_version=fusion_version
            )
            if deps_result.returncode != 0:
                error_console.log("dbt deps returned errors")
                error_console.log(deps_output)

            # Now try parse
            if show_fusion_output:
                console.log("\n\nRunning dbt parse", style="green")
            parse_result = subprocess.run(
                [
                    fusion_binary_name,
                    "parse",
                    "--profile",
                    "test_schema_compat",
                    "--project-dir",
                    str(repo_path),
                    "--log-format",
                    "otel",
                ],
                check=False,
                text=True,
                capture_output=True,
                timeout=60,
            )
            parse_output = parse_log_output(
                parse_result.stdout, parse_result.returncode, repo_path, fusion_version=fusion_version
            )
            if parse_result.returncode != 0:
                error_console.log("dbt parse returned errors")
                error_console.log(parse_output)
                if len(deps_output.errors) > 0:
                    parse_output.errors.extend(deps_output.errors)
        except Exception as e:
            error_console.log(f"{e}: An unknown error occurred when running dbt parse")
            return

        # Return True if exit code is 0 (success)
        is_compatible = parse_result.returncode == 0

        if show_fusion_output:
            if is_compatible:
                console.log(f"Package at {repo_path} is fusion schema compatible")
            else:
                console.log(f"Package at {repo_path} is not fusion schema compatible")

        # Clean up deps
        if show_fusion_output:
            console.log("\n\nRunning dbt clean", style="green")
        subprocess.run(
            [
                fusion_binary_name,
                "clean",
                "--profile",
                "test_schema_compat",
                "--project-dir",
                str(repo_path),
                "--log-format",
                "otel",
            ],
            check=False,
            timeout=60,
            text=True,
            capture_output=True,
        )
        # Remove the test profile
        os.remove(profiles_path)

        return parse_output

    except Exception as e:
        error_console.log(f"Error checking fusion compatibility for {repo_path}: {e!s}")
        try:
            os.remove(profiles_path)
        except Exception:
            pass
        return


def main():
    check_fusion_schema_compatibility(repo_path=Path.cwd())


if __name__ == "__main__":
    main()
